Compare commits

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:29:11 +09:00
CopilotandCopilot 7182b6cbb4 Add project takeoff verification and bearing dimensions
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-06 16:25:53 +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
CopilotandCopilot d22c5008f0 Improve drawing export and foundation quantity views
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-06 15:35:56 +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
CopilotandCopilot a0ec74b09b Improve drawing symbols and sample popup
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-05 17:30:24 +09:00
CopilotandCopilot 7efe9097ce Update landing page and ground link features
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-05 11:32:50 +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
CopilotandCopilot a5cc458252 Add drawing workspace feature
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-04 13:34:51 +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
CopilotandCopilot ad73ddec2b Update Pier_v1 project
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-11-03 12:04:58 +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
2810 changed files with 83347 additions and 6523 deletions

No files matched your search

+3
View File
@@ -12,3 +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/
도면추출/output/
+40 -6
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커밋 앞선 상태였습니다. 실제 원격은 조회하지 않았습니다. 브랜치 전환이나 이력 변경 전에 현재 상태를 확인하세요.
## 실행 ## 실행
@@ -11,19 +19,45 @@ npm run dev -- --host 127.0.0.1 --port 5173
브라우저에서 http://127.0.0.1:5173/ 을 엽니다. 랜딩페이지의 Excel 가져오기와 실제 교량 샘플 선택을 사용합니다. 브라우저에서 http://127.0.0.1:5173/ 을 엽니다. 랜딩페이지의 Excel 가져오기와 실제 교량 샘플 선택을 사용합니다.
랜딩페이지의 **도면 불러오기**를 누르면 프로그램 내부의 도면추출 팝업이 열립니다. DWG 폴더를 선택하고 자동 추출을 시작하면 진행 로그와 결과가 팝업에 표시됩니다. 결과의 **교각 불러오기**로 바로 Excel 인식·형상 생성 단계에 연결하거나 **Excel 저장**으로 파일을 받을 수 있습니다. 원래 추출 엔진은 화면 없이 실행하며 미확정 값은 검토 대상으로 유지합니다.
결과는 프로젝트의 `도면추출/output/작업ID/`에 저장합니다. 선택한 원본 도면 폴더는 수정하지 않습니다. 팝업을 닫아도 추출은 계속되며 다시 열어 결과를 확인할 수 있습니다.
배포 빌드를 로컬에서 실행하려면 `npm run build` 후 `npm run preview -- --host 127.0.0.1`을 사용합니다. 도면추출은 이 PC의 Windows 로컬 서버와 포함된 `도면추출/app` 엔진을 사용하므로, `dist`만 올린 정적 웹사이트에서는 추출을 실행할 수 없습니다.
## 유지하는 폴더 ## 유지하는 폴더
- `src/`, `index.html`: 실제 교각 프로그램 - `src/`, `index.html`: 실제 교각 프로그램
- `랜딩페이지/`: 랜딩 화면과 지도 포함 통합 샘플 12개 - `랜딩페이지/`: 랜딩 화면과 지도 포함 통합 샘플 12개
- `pierzainer_ground/`: 지반 생성 프로그램, 원본 자료, 최종 지반 JSON 및 재현에 필요한 데이터 - `pierzainer_ground/`: 지반 생성 프로그램, 원본 자료, 최종 지반 JSON 및 재현에 필요한 데이터
- `도면추출_연동패키지_20261102/`: 도면 추출 프로그램과 교각 샘플 Excel - `도면추출/`: 도면 추출 프로그램과 교각 샘플 Excel (기본 P1 샘플 포함)
- `public/`: 프로그램 리소스 및 기본 Excel 샘플 - `tests/`, `scripts/`, `verify/`: 현재 교각·Excel·지도 기능의 자동 검사와 유지보수 도구
- `tests/`, `scripts/`, `verify/`: 자동 검사와 유지보수 스크립트
`node_modules/`, `dist/`, `.verify/`, 지반 `cache/`는 실행·검증 중 재생성되는 파일이며 Git에 추가하지 않습니다. `node_modules/`, `dist/`, `.verify/`, 지반 `cache/`는 실행·검증 중 재생성되는 파일이며 Git에 추가하지 않습니다.
## 2026-11-02 혼합 파일 복구 ## 검증
```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 브랜치와 원격 기록은 변경하지 않았습니다. 혼합 전 Pierzainer 커밋 `d6735a6`에서 로컬 브랜치 `recovery/pierzainer-20261102`를 만들었습니다. π형 Excel 인식, 전·후열 받침 수 불일치 허용, 랜딩페이지 샘플 연결을 포함합니다. 기존 ITAM 브랜치와 원격 기록은 변경하지 않았습니다.
복구본과 분리한 파일은 상위 `_recovery/20261102_mixed_projects/`에 보관했습니다. 기존 `master`는 ITAM 코드이므로 그 브랜치로 전환하면 화면과 파일이 다시 바뀝니다. 현재 복구 브랜치에서 작업하세요. 이 작업에서는 원격 push를 수행하지 않았습니다. 당시 복구 기록에는 분리 파일을 상위 `_recovery/20261102_mixed_projects/`에 보관하고, 당시 `master`가 ITAM 코드였다고 적혀 있습니다. 2026-11-05 현재 로컬 `master`는 Pierzainer 코드(`91c4a4a`)이므로 과거 안내에 따라 복구 브랜치로 전환하지 마세요. 당시 복구 보관 경로의 현존 여부는 이번 작업에서 재확인하지 않았습니다. 이번 검증·기록 정리에서도 원격 push와 브랜치 전환은 수행하지 않았습니다.
## 정리 기준
실행에 필요한 코드·원본 도면·샘플 Excel·최종 지반 JSON은 유지합니다. 과거 개발용 도구와 옛 좌표 기준의 검증 파일은 정리했으며 백업 목록은 상위 `_recovery/20261103_cleanup/`에 있습니다.
+82 -26
View File
@@ -11,22 +11,26 @@
<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-quantity" class="step" type="button">④ 재료표 <em id="step4-status">예정</em></button> <button id="nav-drawing" class="step" data-label="④ 도면작성" aria-label="④ 도면작성" type="button">④ 도면 <em id="step-drawing-status"></em></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-import" class="btn-s" type="button">엑셀 불러오기</button> <details id="file-menu" class="file-menu">
<button id="open-project" class="btn-s" type="button">프로젝트 열기</button> <summary class="btn-s">불러오기</summary>
<button id="regen" class="btn-p" type="button" disabled>선택 교각 형상 생성</button> <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 +47,7 @@
<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> <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 +58,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 +75,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 +184,30 @@
<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">
<div class="viewer-slot" id="slot-plan"></div> <div class="viewer-slot" id="slot-plan"></div>
<!-- ② 교대 부재 카드 (시안, 2026-11-03): the card of the row picked in 부재 · 교대, under the 3D view. -->
<section id="abut-panel-plan" class="panel viewer abut-card" hidden aria-labelledby="abut-panel-plan-title">
<div class="viewer-head">
<h2 id="abut-panel-plan-title"><span id="abut-panel-plan-member">교량받침 · 받침블럭 · 솔플레이트</span> <span class="muted small" id="abut-panel-plan-name"></span></h2>
</div>
<div id="abut-panel-plan-bearings" class="edit-row"></div>
<div id="abut-panel-plan-wing" class="edit-row"></div>
<div id="abut-panel-plan-slab" class="edit-row"></div>
<div id="abut-panel-plan-ground" class="edit-row"></div>
<div id="abut-panel-plan-shape" class="edit-row abut-shape-view"></div>
<div id="abut-panel-plan-footing" class="edit-row abut-shape-view"></div>
<p id="abut-panel-plan-msg" class="muted" aria-live="polite"></p>
</section>
<section class="panel viewer" aria-labelledby="member-title"> <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>
@@ -227,6 +267,8 @@
</aside> </aside>
</main> </main>
<main id="view-drawing" hidden aria-label="교각 일반도 작성"></main>
<main class="grid quantity" id="view-quantity" hidden> <main class="grid quantity" id="view-quantity" hidden>
<aside class="panel piers" aria-labelledby="quantity-pier-title"> <aside class="panel piers" aria-labelledby="quantity-pier-title">
<p id="quantity-pier-title" class="lbl">교각 목록</p> <p id="quantity-pier-title" class="lbl">교각 목록</p>
@@ -236,6 +278,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>
@@ -252,7 +295,16 @@
<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 review-workspace" id="review-workspace">
<div class="workspace-tools" aria-label="검토 공간 조절">
<label class="model-height-control" for="review-model-height">모델 높이
<input id="review-model-height" type="range" min="180" max="560" step="20" value="240" />
<output id="review-model-height-value" for="review-model-height">240 px</output>
</label>
<button id="review-model-toggle" class="btn small" type="button" aria-expanded="true" aria-controls="slot-rebar">모델 접기</button>
<button id="review-inputs" class="btn small" type="button">입력 보기</button>
<button id="review-table" class="btn small" type="button">철근표 보기</button>
</div>
<div class="viewer-slot" id="slot-rebar"></div> <div class="viewer-slot" id="slot-rebar"></div>
<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></div>
@@ -263,24 +315,26 @@
<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>
@@ -353,20 +407,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>
@@ -375,9 +431,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>
+1
View File
@@ -4,6 +4,7 @@
"description": "교각 3D 형상 생성 웹앱 (1단계: 형상 생성)", "description": "교각 3D 형상 생성 웹앱 (1단계: 형상 생성)",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"preview": "vite preview",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run"
+1 -1
View File
@@ -16,7 +16,7 @@ import traceback
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
OUTPUT = ROOT / 'data/output/samples_20261102' OUTPUT = ROOT / 'data/output/samples_20261102'
PACKAGE = ROOT.parent / '도면추출_연동패키지_20261102' PACKAGE = ROOT.parent / '도면추출'
def save(path, value): def save(path, value):
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5183", "terrain_crs": "EPSG:5183",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "CSSAA14", "terrain_block": "CSSAA14",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플01_산외교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플01_산외교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "", "layers": "",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 1, "sample": 1,
"bridge": "산외교", "bridge": "산외교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플01_산외교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플01_산외교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "", "terrain_block": "",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플02_운곡교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플02_운곡교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "CA002,CA0021,CAA001,CAA002", "layers": "CA002,CA0021,CAA001,CAA002",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 2, "sample": 2,
"bridge": "운곡교", "bridge": "운곡교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플02_운곡교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플02_운곡교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "*Model_Space", "terrain_block": "*Model_Space",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플03_대호지교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플03_대호지교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "PDF_SOUNDING", "layers": "PDF_SOUNDING",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 3, "sample": 3,
"bridge": "대호지교", "bridge": "대호지교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플03_대호지교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플03_대호지교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\03_original_pdf_soundings.dxf", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\03_original_pdf_soundings.dxf",
"candidates": [], "candidates": [],
"output": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\output\\samples_20261102\\03_대호지교", "output": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\output\\samples_20261102\\03_대호지교",
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "", "terrain_block": "",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플04_목동교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플04_목동교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "CA002,CA0021,CAA001,CAA002", "layers": "CA002,CA0021,CAA001,CAA002",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 4, "sample": 4,
"bridge": "목동교", "bridge": "목동교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플04_목동교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플04_목동교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\04_@서울-세종 9공구 1000현황.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\04_@서울-세종 9공구 1000현황.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample4)_π형교각_목동교\\01 공통사항(목동교 종평면도).dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample4)_π형교각_목동교\\01 공통사항(목동교 종평면도).dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "", "terrain_block": "",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플05_승천천교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플05_승천천교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "F0017111,F0017114,F0027217,F0027132", "layers": "F0017111,F0017114,F0027217,F0027132",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 5, "sample": 5,
"bridge": "승천천교", "bridge": "승천천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플05_승천천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플05_승천천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\05_project_topography_original.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\05_project_topography_original.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample5)_π형교각_승천천교\\01 승천천교(DR) 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample5)_π형교각_승천천교\\01 승천천교(DR) 종평면도.dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "xref-현황(191219)", "terrain_block": "xref-현황(191219)",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플06_계문철교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플06_계문철교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "", "layers": "",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 6, "sample": 6,
"bridge": "계문철교", "bridge": "계문철교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플06_계문철교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플06_계문철교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample6)_π형교각_계문철교\\01 계문철교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample6)_π형교각_계문철교\\01 계문철교 종평면도.dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "xref-현황(191219)", "terrain_block": "xref-현황(191219)",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플07_기양천교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플07_기양천교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "", "layers": "",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 7, "sample": 7,
"bridge": "기양천교", "bridge": "기양천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플07_기양천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플07_기양천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample7)_π형교각_기양천교\\01 기양천교종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample7)_π형교각_기양천교\\01 기양천교종평면도.dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "XR_현황", "terrain_block": "XR_현황",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플08_옥성교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플08_옥성교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "", "layers": "",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 8, "sample": 8,
"bridge": "옥성교", "bridge": "옥성교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플08_옥성교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플08_옥성교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "*Model_Space", "terrain_block": "*Model_Space",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플09_새재1교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플09_새재1교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "XR_현황-수정$0$Point", "layers": "XR_현황-수정$0$Point",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 9, "sample": 9,
"bridge": "새재1교", "bridge": "새재1교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플09_새재1교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플09_새재1교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "*Model_Space", "terrain_block": "*Model_Space",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플10_도하4교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플10_도하4교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "CA002,CA0021,CAA001,CAA002", "layers": "CA002,CA0021,CAA001,CAA002",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 10, "sample": 10,
"bridge": "도하4교", "bridge": "도하4교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플10_도하4교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플10_도하4교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5186", "terrain_crs": "EPSG:5186",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "*Model_Space", "terrain_block": "*Model_Space",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플11_곡천교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플11_곡천교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "CA002,CA0021,CAA001,CAA002", "layers": "CA002,CA0021,CAA001,CAA002",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 11, "sample": 11,
"bridge": "곡천교", "bridge": "곡천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플11_곡천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플11_곡천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg"
@@ -5,7 +5,7 @@
"terrain_crs": "EPSG:5181", "terrain_crs": "EPSG:5181",
"cad_axes": "YX", "cad_axes": "YX",
"terrain_block": "", "terrain_block": "",
"piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플12_감북교_교각입력데이터.xlsx", "piers": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플12_감북교_교각입력데이터.xlsx",
"boreholes": "", "boreholes": "",
"layers": "CAA001,CAA002", "layers": "CAA001,CAA002",
"margin": 100.0, "margin": 100.0,
@@ -1,7 +1,7 @@
{ {
"sample": 12, "sample": 12,
"bridge": "감북교", "bridge": "감북교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플12_감북교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플12_감북교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg"
@@ -2,7 +2,7 @@
{ {
"sample": 1, "sample": 1,
"bridge": "산외교", "bridge": "산외교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플01_산외교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플01_산외교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg"
@@ -12,7 +12,7 @@
{ {
"sample": 2, "sample": 2,
"bridge": "운곡교", "bridge": "운곡교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플02_운곡교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플02_운곡교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg"
@@ -22,7 +22,7 @@
{ {
"sample": 3, "sample": 3,
"bridge": "대호지교", "bridge": "대호지교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플03_대호지교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플03_대호지교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\03_original_pdf_soundings.dxf", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\03_original_pdf_soundings.dxf",
"candidates": [], "candidates": [],
"output": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\output\\samples_20261102\\03_대호지교", "output": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\output\\samples_20261102\\03_대호지교",
@@ -930,7 +930,7 @@
{ {
"sample": 4, "sample": 4,
"bridge": "목동교", "bridge": "목동교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플04_목동교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플04_목동교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\04_@서울-세종 9공구 1000현황.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\04_@서울-세종 9공구 1000현황.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample4)_π형교각_목동교\\01 공통사항(목동교 종평면도).dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample4)_π형교각_목동교\\01 공통사항(목동교 종평면도).dwg"
@@ -956,7 +956,7 @@
{ {
"sample": 5, "sample": 5,
"bridge": "승천천교", "bridge": "승천천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플05_승천천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플05_승천천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\05_project_topography_original.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\05_project_topography_original.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample5)_π형교각_승천천교\\01 승천천교(DR) 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample5)_π형교각_승천천교\\01 승천천교(DR) 종평면도.dwg"
@@ -984,7 +984,7 @@
{ {
"sample": 6, "sample": 6,
"bridge": "계문철교", "bridge": "계문철교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플06_계문철교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플06_계문철교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample6)_π형교각_계문철교\\01 계문철교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample6)_π형교각_계문철교\\01 계문철교 종평면도.dwg"
@@ -1011,7 +1011,7 @@
{ {
"sample": 7, "sample": 7,
"bridge": "기양천교", "bridge": "기양천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플07_기양천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플07_기양천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample7)_π형교각_기양천교\\01 기양천교종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample7)_π형교각_기양천교\\01 기양천교종평면도.dwg"
@@ -1038,7 +1038,7 @@
{ {
"sample": 8, "sample": 8,
"bridge": "옥성교", "bridge": "옥성교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플08_옥성교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플08_옥성교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg"
@@ -1048,7 +1048,7 @@
{ {
"sample": 9, "sample": 9,
"bridge": "새재1교", "bridge": "새재1교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플09_새재1교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플09_새재1교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg"
@@ -1058,7 +1058,7 @@
{ {
"sample": 10, "sample": 10,
"bridge": "도하4교", "bridge": "도하4교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플10_도하4교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플10_도하4교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg"
@@ -1068,7 +1068,7 @@
{ {
"sample": 11, "sample": 11,
"bridge": "곡천교", "bridge": "곡천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플11_곡천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플11_곡천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg"
@@ -1078,7 +1078,7 @@
{ {
"sample": 12, "sample": 12,
"bridge": "감북교", "bridge": "감북교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플12_감북교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플12_감북교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg"
@@ -7,7 +7,7 @@
{ {
"sample": 1, "sample": 1,
"bridge": "산외교", "bridge": "산외교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플01_산외교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플01_산외교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample1)_π형교각_산외교\\02.종평면도(울산)_2017.0619-수정.dwg"
@@ -41,7 +41,7 @@
{ {
"sample": 2, "sample": 2,
"bridge": "운곡교", "bridge": "운곡교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플02_운곡교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플02_운곡교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample2)_T형교각_운곡교\\C0050202-001-운곡교 종평면도(완도방향).dwg"
@@ -75,7 +75,7 @@
{ {
"sample": 3, "sample": 3,
"bridge": "대호지교", "bridge": "대호지교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플03_대호지교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플03_대호지교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\03_original_pdf_soundings.dxf", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\03_original_pdf_soundings.dxf",
"candidates": [], "candidates": [],
"output": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\output\\samples_20261102\\03_대호지교", "output": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\output\\samples_20261102\\03_대호지교",
@@ -1007,7 +1007,7 @@
{ {
"sample": 4, "sample": 4,
"bridge": "목동교", "bridge": "목동교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플04_목동교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플04_목동교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\04_@서울-세종 9공구 1000현황.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\04_@서울-세종 9공구 1000현황.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample4)_π형교각_목동교\\01 공통사항(목동교 종평면도).dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample4)_π형교각_목동교\\01 공통사항(목동교 종평면도).dwg"
@@ -1057,7 +1057,7 @@
{ {
"sample": 5, "sample": 5,
"bridge": "승천천교", "bridge": "승천천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플05_승천천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플05_승천천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\05_project_topography_original.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\05_project_topography_original.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample5)_π형교각_승천천교\\01 승천천교(DR) 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample5)_π형교각_승천천교\\01 승천천교(DR) 종평면도.dwg"
@@ -1109,7 +1109,7 @@
{ {
"sample": 6, "sample": 6,
"bridge": "계문철교", "bridge": "계문철교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플06_계문철교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플06_계문철교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample6)_π형교각_계문철교\\01 계문철교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample6)_π형교각_계문철교\\01 계문철교 종평면도.dwg"
@@ -1160,7 +1160,7 @@
{ {
"sample": 7, "sample": 7,
"bridge": "기양천교", "bridge": "기양천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플07_기양천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플07_기양천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg", "drawing": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\pierzainer_ground\\data\\input\\samples_20261102\\recovered\\06_서수평장1-현황+선형+노리-20200410.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample7)_π형교각_기양천교\\01 기양천교종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample7)_π형교각_기양천교\\01 기양천교종평면도.dwg"
@@ -1211,7 +1211,7 @@
{ {
"sample": 8, "sample": 8,
"bridge": "옥성교", "bridge": "옥성교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플08_옥성교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플08_옥성교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample8)_T형교각_옥성교\\01 옥성교 종평면도.dwg"
@@ -1245,7 +1245,7 @@
{ {
"sample": 9, "sample": 9,
"bridge": "새재1교", "bridge": "새재1교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플09_새재1교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플09_새재1교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample9)_T형교각_새재1교\\01 새재1교 종평면도.dwg"
@@ -1279,7 +1279,7 @@
{ {
"sample": 10, "sample": 10,
"bridge": "도하4교", "bridge": "도하4교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플10_도하4교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플10_도하4교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample10)_T형교각_도하4교\\01.종평면도.dwg"
@@ -1313,7 +1313,7 @@
{ {
"sample": 11, "sample": 11,
"bridge": "곡천교", "bridge": "곡천교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플11_곡천교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플11_곡천교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample11)_π형교각_곡천교\\01 종평면도_곡천교.dwg"
@@ -1347,7 +1347,7 @@
{ {
"sample": 12, "sample": 12,
"bridge": "감북교", "bridge": "감북교",
"workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출_연동패키지_20261102\\samples\\샘플12_감북교_교각입력데이터.xlsx", "workbook": "C:\\Users\\User\\Desktop\\Pier_v1-main\\Pier_v1\\도면추출\\samples\\샘플12_감북교_교각입력데이터.xlsx",
"drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg", "drawing": "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg",
"candidates": [ "candidates": [
"C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg" "C:\\Users\\User\\Desktop\\도면추출\\엔지니어(sample12)_π형교각_감북교\\01 감북교 종평면도.dwg"
+15
View File
@@ -0,0 +1,15 @@
# 프로그램 내부 지반 생성 팝업
현재 지반 생성 버튼은 별도 Windows 창 대신 프로그램 내부 dialog를 연다.
- Excel 시작: 현재 Excel이 연결되며 종평면도 DWG/DXF를 선택한 뒤 지반 생성을 누른다.
- 도면 시작: 추출 폴더의 종평면도 원본과 Excel이 연결되며 지반 생성을 누른다.
- 입력을 바꾸려면 팝업의 파일 선택을 사용한다. Excel 연결 정보가 없는 프로젝트는 Excel도 선택한다.
- 기존 Python build_from_files 엔진을 창 없이 실행한다. BAT를 브라우저 안에 직접 삽입하는 방식이 아니며, 독립 실행용 BAT는 유지한다.
- 진행 로그와 오류를 팝업에 표시한다. 닫아도 작업은 계속되며 다시 열 수 있다.
- 완료 결과는 기존 JSON 로더에 전달한다. 지반·지층 패널을 벗어나면 복귀 후 반영한다.
- 결과는 data/output/app_<작업 ID>에 저장한다. 원본 자료는 변경하지 않는다.
- Windows 로컬 Vite 서버가 필요하다. 정적 호스팅에서는 실행할 수 없다.
검증: TypeScript 검사 및 기존 후보 선택 테스트 3건 통과. 브라우저에서 실제 prepare API, 팝업 표시, 입력 누락 오류, 닫기 및 즉시 재열기 확인. 이번 팝업 수정에서 새 도면 전체 생성은 실행하지 않았다.
주요 파일: ground-link.ts, ground-link-server.ts, ground-popup.css, pierzainer_ground/web_job.py. 기존 main/landing 입력 연결을 재사용한다.
+59
View File
@@ -0,0 +1,59 @@
import { spawn } from 'node:child_process';
import { mkdir,writeFile,readFile,readdir } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import type { Plugin } from 'vite';
export const GROUND_LINK_API='/__pierzainer/ground-link';
export function startGroundBat(bat:string,context:string,cwd:string){
return spawn(process.env.ComSpec??'cmd.exe',['/d','/s','/c',`""${bat}""`],{cwd,windowsHide:true,windowsVerbatimArguments:true,env:{...process.env,PIERZAINER_GROUND_CONTEXT:context},stdio:'ignore'});
}
export async function findGroundDrawing(folder:string):Promise<string|undefined>{
const matches:string[]=[];
const walk=async(dir:string,depth:number)=>{for(const entry of await readdir(dir,{withFileTypes:true})){const file=path.join(dir,entry.name);if(entry.isDirectory()&&depth<2)await walk(file,depth+1);else if(entry.isFile()&&/\.(dwg|dxf)$/i.test(entry.name)&&/종평면|종평|종단|평종면/.test(entry.name))matches.push(file);}};
if(folder)await walk(folder,0);const originals=matches.filter(file=>/\.dwg$/i.test(file));return originals.length===1?originals[0]:matches.length===1?matches[0]:undefined;
}
export function groundLinkPlugin(root:string):Plugin{
const base=path.join(root,'pierzainer_ground'),cache=path.join(base,'data/cache/app-link'),stages=new Map<string,{excel:string;folder:string;name:string}>(),jobs=new Map<string,{output:string;logs:string;state:string}>();
const handler=async(req:any,res:any,next:()=>void)=>{
const pathname=new URL(req.url??'/','http://localhost').pathname;if(!pathname.startsWith(GROUND_LINK_API+'/'))return next();
const reply=(code:number,value:unknown)=>{res.writeHead(code,{'Content-Type':'application/json; charset=utf-8','Cache-Control':'no-store'});res.end(JSON.stringify(value));};
const host=req.headers.host??'',address=req.socket.remoteAddress??'';
if(req.method!=='POST'||! /^(localhost|127\.0\.0\.1|\[::1\])(?::\d+)?$/i.test(host)||!['127.0.0.1','::1','::ffff:127.0.0.1'].includes(address)||req.headers['x-pierzainer-action']!=='ground-link'||req.headers.origin&&req.headers.origin!==`http://${host}`&&req.headers.origin!==`https://${host}`){reply(403,{error:'이 PC의 Pierzainer에서 실행하세요.'});return;}
try{
let raw='';for await(const part of req){raw+=part;if(raw.length>280*1024*1024)throw new Error('Excel 파일이 너무 큽니다(최대 20MB).');}const body=JSON.parse(raw||'{}'),action=pathname.slice(GROUND_LINK_API.length+1);
if(action==='stage'){
if(typeof body.name!=='string'||!body.name.toLowerCase().endsWith('.xlsx')||typeof body.base64!=='string'||!/^[A-Za-z0-9+/]*={0,2}$/.test(body.base64))throw new Error('교각 Excel(.xlsx) 파일을 선택하세요.');
const bytes=Buffer.from(body.base64,'base64');if(bytes.length>20*1024*1024||bytes[0]!==80||bytes[1]!==75)throw new Error('유효한 xlsx 파일이 아닙니다.');
const id=randomUUID(),dir=path.join(cache,id);await mkdir(dir,{recursive:true});const excel=path.join(dir,'piers.xlsx');await writeFile(excel,bytes);stages.set(id,{excel,name:body.name,folder:typeof body.folder==='string'?body.folder:''});reply(200,{id});return;
}
if(action==='prepare'){
const stage=stages.get(body.stageId);const drawing=stage?.folder?await findGroundDrawing(stage.folder):undefined;reply(200,{drawing:drawing??'',excelSelected:!!stage,excelName:stage?.name??''});return;
}
if(action==='upload'){
if(typeof body.name!=='string'||!/\.(dwg|dxf|xlsx)$/i.test(body.name)||typeof body.base64!=='string'||! /^[A-Za-z0-9+/]*={0,2}$/.test(body.base64))throw new Error('DWG/DXF 또는 Excel을 선택하세요.');
const bytes=Buffer.from(body.base64,'base64');if(bytes.length>200*1024*1024)throw new Error('최대 200MB입니다.');const dir=path.join(cache,randomUUID());await mkdir(dir,{recursive:true});const file=path.join(dir,path.basename(body.name));await writeFile(file,bytes);reply(200,{file});return;
}
if(action==='launch'){
const stage=stages.get(body.stageId),drawing=String(body.drawing??''),piers=String(body.excel??stage?.excel??'');
const original=stage?.folder?await findGroundDrawing(stage.folder):undefined;
const cached=(file:string)=>{const r=path.relative(cache,path.resolve(file));return !!r&&!r.startsWith('..')&&!path.isAbsolute(r);};
if(!existsSync(drawing)||!/\.(dwg|dxf)$/i.test(drawing)||drawing!==original&&!cached(drawing))throw new Error('종평면도를 선택하세요.');
if(!existsSync(piers)||!/\.xlsx$/i.test(piers)||!cached(piers))throw new Error('교각 Excel을 선택하세요.');
const id=randomUUID(),output=path.join(base,'data/output',`app_${id}`),job={output,logs:'',state:'running'};jobs.set(id,job);
const python=existsSync(path.join(base,'.venv/Scripts/python.exe'))?path.join(base,'.venv/Scripts/python.exe'):'python';
const child=spawn(python,['-B','-u','-X','utf8','-m','pierzainer_ground.web_job',drawing,piers,output],{cwd:base,windowsHide:true,stdio:['ignore','pipe','pipe']});
const append=(data:Buffer)=>{job.logs=(job.logs+data.toString('utf8')).slice(-200000);};child.stdout.on('data',append);child.stderr.on('data',append);
child.once('error',error=>{job.state='error';job.logs+='\n'+error.message;});child.once('close',code=>{job.state=code===0?'done':'error';});reply(200,{id});return;
}
if(action==='result'){
const job=jobs.get(body.id);if(!job)throw new Error('지반 생성 작업을 찾을 수 없습니다.');
if(job.state!=='done'){reply(200,{ready:false,state:job.state,logs:job.logs});return;}
for(const filename of ['ground_model_3d_with_maps.json','ground_model.json']){const file=path.join(job.output,filename);if(existsSync(file)){const text=await readFile(file,'utf8');JSON.parse(text);reply(200,{ready:true,filename,text,state:job.state,logs:job.logs});return;}}
reply(200,{ready:false});return;
}reply(404,{error:'지원하지 않는 지반 연동 요청입니다.'});
}catch(error){reply(400,{error:(error as Error).message});}
};
return{name:'pierzainer-ground-link',configureServer(server){server.middlewares.use((req,res,next)=>void handler(req,res,next));},configurePreviewServer(server){server.middlewares.use((req,res,next)=>void handler(req,res,next));}};
}
+11
View File
@@ -0,0 +1,11 @@
import { describe,it,expect } from 'vitest';
import { mkdir,writeFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { findGroundDrawing } from './ground-link-server';
const base=path.join(import.meta.dirname,'data/cache/link-tests');
describe('지반 프로그램 원본 종평면도 연결',()=>{
it('Excel 시작은 도면을 임의로 지정하지 않는다',async()=>expect(await findGroundDrawing('')).toBeUndefined());
it('폴더의 종평면도 원본 DWG를 선택하고 변환 DXF와 중복하지 않는다',async()=>{const dir=path.join(base,randomUUID());await mkdir(dir,{recursive:true});await writeFile(path.join(dir,'산외교 종평면도.dwg'),'');await writeFile(path.join(dir,'종평면도.dxf'),'');await writeFile(path.join(dir,'받침배치도.dwg'),'');expect(await findGroundDrawing(dir)).toBe(path.join(dir,'산외교 종평면도.dwg'));});
it('서로 다른 종평면도 원본이 여럿이면 사용자 선택으로 남긴다',async()=>{const dir=path.join(base,randomUUID());await mkdir(dir,{recursive:true});await writeFile(path.join(dir,'A교 종평면도.dwg'),'');await writeFile(path.join(dir,'B교 종평면도.dwg'),'');expect(await findGroundDrawing(dir)).toBeUndefined();});
});
+66
View File
@@ -0,0 +1,66 @@
import './ground-popup.css';
const API='/__pierzainer/ground-link';
let pending:{file:File;folder?:string}|undefined,staged:Promise<string|undefined>=Promise.resolve(undefined),version=0;
const request=async(action:string,body:object)=>{const r=await fetch(`${API}/${action}`,{method:'POST',headers:{'Content-Type':'application/json','X-Pierzainer-Action':'ground-link'},body:JSON.stringify(body)});const value=await r.json();if(!r.ok)throw new Error(value.error??'지반 프로그램 연동 실패');return value;};
const base64=(file:File)=>new Promise<string>((resolve,reject)=>{const reader=new FileReader();reader.onerror=()=>reject(new Error('Excel 파일을 읽지 못했습니다.'));reader.onload=()=>resolve(String(reader.result).split(',')[1]);reader.readAsDataURL(file);});
document.addEventListener('change',event=>{const input=event.target as HTMLInputElement;if(input.id==='file'&&input.files?.[0])pending={file:input.files[0]};if(input.id==='project-file'){pending=undefined;version++;staged=Promise.resolve(undefined);}},true);
window.addEventListener('pierzainer:ground-source',(event:Event)=>{const d=(event as CustomEvent).detail;pending={file:new File([d.bytes],d.name,{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'}),folder:d.folder};});
document.addEventListener('click',event=>{if(!(event.target as Element).closest('#dlg-apply')||!pending)return;const source=pending;pending=undefined;version++;staged=(async()=>{const data=await request('stage',{name:source.file.name,base64:await base64(source.file),folder:source.folder});return data.id as string;})();staged.catch(()=>{});},true);
let opening=false,running=false,launchSerial=0;
let popup:HTMLDialogElement;
function getPopup(){
if(popup)return popup;
popup=document.createElement('dialog');popup.className='ground-popup';popup.setAttribute('aria-labelledby','ground-popup-title');
popup.innerHTML=`<header><div><small>GROUND MODEL</small><h2 id="ground-popup-title">지반 모델 생성</h2></div><button type="button" data-close aria-label="닫기">×</button></header>
<p>종평면도와 교각 Excel로 지반면·위성사진·2D 지도 JSON을 생성합니다.</p>
<label for="ground-drawing-file">종평면도 DXF / DWG</label><div class="ground-file-row"><span data-drawing-name></span><button type="button" data-pick="drawing">종평면도 선택</button><input id="ground-drawing-file" data-drawing type="file" accept=".dwg,.dxf" hidden></div>
<label for="ground-excel-file">교각 Excel</label><div class="ground-file-row"><span data-excel-name></span><button type="button" data-pick="excel">Excel 선택</button><input id="ground-excel-file" data-excel type="file" accept=".xlsx" hidden></div>
<p class="ground-popup-note">기존 생성 엔진을 사용합니다. 지도 다운로드에는 인터넷 연결이 필요합니다.</p>
<button type="button" data-run>지반 생성</button><p data-status role="status" aria-live="polite"></p><pre data-logs tabindex="0" aria-label="생성 진행 로그"></pre><footer><button type="button" data-close>닫기</button></footer>`;
popup.querySelectorAll<HTMLButtonElement>('[data-pick]').forEach(button=>button.addEventListener('click',()=>popup.querySelector<HTMLInputElement>(`[data-${button.dataset.pick}]`)!.click()));
popup.querySelectorAll('[data-close]').forEach(button=>button.addEventListener('click',()=>popup.close()));document.body.append(popup);return popup;
}
window.addEventListener('pierzainer:open-ground',async()=>{
if(opening&&popup?.open)return;if(running&&popup){popup.showModal();opening=true;return;}opening=true;
const dialog=getPopup(),ticket=++launchSerial,snapshot=version;
const status=dialog.querySelector<HTMLElement>('[data-status]')!,logs=dialog.querySelector<HTMLElement>('[data-logs]')!,run=dialog.querySelector<HTMLButtonElement>('[data-run]')!;
const drawingInput=dialog.querySelector<HTMLInputElement>('[data-drawing]')!,excelInput=dialog.querySelector<HTMLInputElement>('[data-excel]')!;
drawingInput.value='';excelInput.value='';drawingInput.disabled=excelInput.disabled=false;dialog.querySelector('[data-drawing-name]')!.textContent='파일 확인 중…';dialog.querySelector('[data-excel-name]')!.textContent='파일 확인 중…';run.disabled=true;status.textContent='입력 파일 확인 중…';logs.textContent='';dialog.showModal();
let stageId:string|undefined,drawing='';
try{stageId=await staged;const prepared=await request('prepare',{stageId});drawing=prepared.drawing;
const display=(kind:string,name:string,connected:boolean)=>{dialog.querySelector(`[data-${kind}-name]`)!.textContent=name;dialog.querySelector(`[data-pick="${kind}"]`)!.textContent=connected?'파일 변경':kind==='drawing'?'종평면도 선택':'Excel 선택';};
const drawingName=drawing.split(/[\\/]/).pop()||'종평면도를 선택하세요.';const excelName=prepared.excelName||'교각 Excel을 선택하세요.';
display('drawing',drawingName,!!drawing);display('excel',excelName,prepared.excelSelected);
drawingInput.onchange=()=>display('drawing',drawingInput.files?.[0]?.name||drawingName,!!drawingInput.files?.[0]||!!drawing);
excelInput.onchange=()=>display('excel',excelInput.files?.[0]?.name||excelName,!!excelInput.files?.[0]||prepared.excelSelected);status.textContent='입력을 확인하고 지반 생성을 누르세요.';run.disabled=false;
}catch(error){status.textContent=(error as Error).message;opening=false;return;}
run.onclick=async()=>{
if(snapshot!==version){status.textContent='프로젝트가 변경되었습니다. 닫고 다시 열어 주세요.';return;}
run.disabled=true;drawingInput.disabled=excelInput.disabled=true;
try{
const upload=async(file:File)=>(await request('upload',{name:file.name,base64:await base64(file)})).file as string;
status.textContent='입력 파일 전달 중…';const chosen=drawingInput.files?.[0],excel=excelInput.files?.[0];
if(chosen)drawing=await upload(chosen);if(!drawing)throw new Error('종평면도를 선택하세요.');
const excelPath=excel?await upload(excel):undefined;
if(!excelPath&&!stageId)throw new Error('교각 Excel을 선택하세요.');
const job=await request('launch',{stageId,drawing,excel:excelPath});running=true;status.textContent='지반·지도 생성 중… 닫아도 생성은 계속됩니다.';
const deadline=Date.now()+30*60*1000;
const poll=async()=>{
if(snapshot!==version||ticket!==launchSerial){running=false;return;}
try{
const result=await request('result',{id:job.id});logs.textContent=result.logs??'';logs.scrollTop=logs.scrollHeight;
if(result.state==='error')throw new Error('지반 생성 실패. 진행 로그를 확인하세요.');
if(result.ready){
const input=document.querySelector<HTMLInputElement>('#ground-import');
status.textContent=input?'지반 생성 완료. 프로젝트에 불러왔습니다.':'지반 생성 완료. 지반·지층 화면으로 돌아오면 불러옵니다.';
if(input){const transfer=new DataTransfer();transfer.items.add(new File([result.text],result.filename,{type:'application/json'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));running=false;run.disabled=false;drawingInput.disabled=excelInput.disabled=false;return;}
}
if(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;}
};setTimeout(poll,1000);
}catch(error){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.
document.addEventListener('close',event=>{if(event.target===popup)opening=false;},true);
+5
View File
@@ -0,0 +1,5 @@
.ground-popup{box-sizing:border-box;width:min(860px,94vw);max-height:90dvh;overflow:auto;border:1px solid #d2dfed;border-radius:16px;padding:26px;background:#fff;color:#14263d;box-shadow:0 24px 90px #10263f40;font-family:inherit}
.ground-popup::backdrop{background:#10263f70;backdrop-filter:blur(3px)}
.ground-popup header{display:flex;align-items:center;justify-content:space-between}.ground-popup h2{margin:6px 0 16px;font-size:23px}.ground-popup small{color:#087fcb;letter-spacing:.14em}
.ground-popup button{font:inherit;cursor:pointer;padding:9px 18px;border-radius:7px;border:1px solid #cbd9e8;background:#f2f7fc;color:#14263d}.ground-popup button:disabled{opacity:.5;cursor:wait}.ground-popup [data-run]{background:#087fcb;color:white;border-color:#087fcb;margin-top:18px}.ground-popup label{display:block;font-weight:600;margin-top:20px}.ground-popup input{display:block;margin-top:9px;max-width:100%;font:inherit}.ground-popup [data-drawing-name],.ground-popup [data-excel-name]{overflow-wrap:anywhere;font-size:12px;color:#64748b;margin-top:7px}.ground-popup-note{font-size:12px;color:#64748b}.ground-popup pre{height:210px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;background:#f5f8fc;border:1px solid #dce6f0;border-radius:8px;padding:12px;font-size:12px}.ground-popup footer{text-align:right}.ground-popup [data-status]{font-size:13px}
.ground-popup .ground-file-row{display:flex;align-items:center;gap:14px;margin-top:10px;padding:12px 14px;border:1px solid #dce6f0;border-radius:8px;background:#f8fafc}.ground-popup .ground-file-row span{flex:1;min-width:0;overflow-wrap:anywhere;font-size:14px;color:#14263d;margin:0}.ground-popup .ground-file-row button{flex-shrink:0}.ground-popup input[hidden]{display:none}
+25 -6
View File
@@ -1,6 +1,7 @@
"""Two-file, AI-free ground-model generator.""" """Two-file, AI-free ground-model generator."""
from pathlib import Path from pathlib import Path
import os import os
import json
import queue import queue
import re import re
import threading import threading
@@ -10,16 +11,34 @@ from tkinter import filedialog, messagebox, scrolledtext, ttk
from .drawing_job import ROOT, build_from_files from .drawing_job import ROOT, build_from_files
def read_launch_context():
filename=os.environ.get('PIERZAINER_GROUND_CONTEXT')
if not filename:return {}
file=Path(filename).resolve()
if not file.is_relative_to((ROOT/'data/cache').resolve()):raise ValueError('지반 연동 설정 경로가 올바르지 않습니다.')
data=json.loads(file.read_text(encoding='utf-8'))
for key,extensions in [('drawing',('.dwg','.dxf')),('piers',('.xlsx',))]:
value=data.get(key,'')
if value and (not Path(value).is_file() or Path(value).suffix.lower() not in extensions):raise ValueError(f'{key} 입력 파일이 없습니다.')
if data.get('output') and not Path(data['output']).resolve().is_relative_to((ROOT/'data/output').resolve()):raise ValueError('결과 폴더는 지반 프로젝트 내부여야 합니다.')
if data.get('ready_file') and not Path(data['ready_file']).resolve().is_relative_to((ROOT/'data/cache').resolve()):raise ValueError('실행 확인 경로가 올바르지 않습니다.')
return data
class GroundModelApp(tk.Tk): class GroundModelApp(tk.Tk):
def __init__(self): def __init__(self,context=None):
super().__init__() super().__init__()
self.title('Pierzainer 지반모델링 · 도면 + Excel 자동 생성 v2') self.title('Pierzainer 지반모델링 · 도면 + Excel 자동 생성 v2')
self.geometry('1000x640');self.minsize(850,540) self.geometry('1000x640');self.minsize(850,540)
self.drawing=tk.StringVar();self.piers=tk.StringVar() context=context or {};self._linked_output=context.get('output')
self.drawing=tk.StringVar(value=context.get('drawing',''));self.piers=tk.StringVar(value=context.get('piers',''))
self.output=tk.StringVar(value=str(ROOT/'data/output')) self.output=tk.StringVar(value=str(ROOT/'data/output'))
self.status=tk.StringVar(value='종평면도와 교각 Excel을 선택하세요') self.status=tk.StringVar(value='종평면도와 교각 Excel을 선택하세요')
self.messages=queue.Queue();self.busy=False self.messages=queue.Queue();self.busy=False
self._build();self.after(100,self._poll) self._build();self.after(100,self._poll)
if self._linked_output or self.drawing.get():self._output_folder()
if context.get('message'):self.status.set(context['message'])
if context.get('ready_file'):Path(context['ready_file']).write_text('ready',encoding='utf-8')
def _build(self): def _build(self):
main=ttk.Frame(self,padding=16);main.pack(fill='both',expand=True) main=ttk.Frame(self,padding=16);main.pack(fill='both',expand=True)
@@ -28,12 +47,12 @@ class GroundModelApp(tk.Tk):
for row,key,label in ((1,'drawing','① 종평면도 DXF/DWG'),(2,'piers','② 교각 Excel')): for row,key,label in ((1,'drawing','① 종평면도 DXF/DWG'),(2,'piers','② 교각 Excel')):
ttk.Label(main,text=label).grid(row=row,column=0,sticky='w',pady=6) ttk.Label(main,text=label).grid(row=row,column=0,sticky='w',pady=6)
ttk.Entry(main,textvariable=getattr(self,key)).grid(row=row,column=1,sticky='ew',padx=10) ttk.Entry(main,textvariable=getattr(self,key)).grid(row=row,column=1,sticky='ew',padx=10)
ttk.Button(main,text='찾기',command=lambda k=key:self._browse(k)).grid(row=row,column=2) ttk.Button(main,text='종평면도 선택하기' if key=='drawing' else 'Excel 선택하기',command=lambda k=key:self._browse(k)).grid(row=row,column=2)
ttk.Label(main,text='결과 저장 위치').grid(row=3,column=0,sticky='w',pady=6) ttk.Label(main,text='결과 저장 위치').grid(row=3,column=0,sticky='w',pady=6)
ttk.Entry(main,textvariable=self.output,state='readonly').grid(row=3,column=1,columnspan=2,sticky='ew',padx=(10,0)) ttk.Entry(main,textvariable=self.output,state='readonly').grid(row=3,column=1,columnspan=2,sticky='ew',padx=(10,0))
ttk.Label(main,text='두 파일만 선택하면 지반면 + 위성사진 + 2D 지도를 생성합니다. AI 연결 없이 OSM/Esri 인터넷 지도를 사용합니다.\n원본에 좌표계 표기가 없으면 프로젝트 기본 EPSG:5186을 적용하고, 좌표계 가정으로 표시합니다.',foreground='#4f6070',wraplength=920).grid(row=4,column=0,columnspan=3,sticky='w',pady=12) ttk.Label(main,text='두 파일만 선택하면 지반면 + 위성사진 + 2D 지도를 생성합니다. AI 연결 없이 OSM/Esri 인터넷 지도를 사용합니다.\n원본에 좌표계 표기가 없으면 프로젝트 기본 EPSG:5186을 적용하고, 좌표계 가정으로 표시합니다.',foreground='#4f6070',wraplength=920).grid(row=4,column=0,columnspan=3,sticky='w',pady=12)
buttons=ttk.Frame(main);buttons.grid(row=5,column=0,columnspan=3,sticky='ew',pady=6) buttons=ttk.Frame(main);buttons.grid(row=5,column=0,columnspan=3,sticky='ew',pady=6)
self.run_button=ttk.Button(buttons,text='지반 모델 자동 생성',command=self._start_run);self.run_button.pack(side='left') self.run_button=ttk.Button(buttons,text='지반 생성',command=self._start_run);self.run_button.pack(side='left')
ttk.Button(buttons,text='결과 폴더 열기',command=self._open_out).pack(side='left',padx=10) ttk.Button(buttons,text='결과 폴더 열기',command=self._open_out).pack(side='left',padx=10)
ttk.Label(main,textvariable=self.status).grid(row=6,column=0,columnspan=3,sticky='w',pady=6) ttk.Label(main,textvariable=self.status).grid(row=6,column=0,columnspan=3,sticky='w',pady=6)
self.log=scrolledtext.ScrolledText(main,height=18,font=('Consolas',10)) self.log=scrolledtext.ScrolledText(main,height=18,font=('Consolas',10))
@@ -52,7 +71,7 @@ class GroundModelApp(tk.Tk):
def _output_folder(self): def _output_folder(self):
name=re.sub(r'[^\w.-]+','_',Path(self.drawing.get()).stem).strip('.') or 'new_drawing' name=re.sub(r'[^\w.-]+','_',Path(self.drawing.get()).stem).strip('.') or 'new_drawing'
self.output.set(str(ROOT/'data/output'/('drawing_'+name))) self.output.set(self._linked_output or str(ROOT/'data/output'/('drawing_'+name)))
def _open_out(self): def _open_out(self):
path=Path(self.output.get()) path=Path(self.output.get())
@@ -93,7 +112,7 @@ class GroundModelApp(tk.Tk):
def main(): def main():
app=GroundModelApp();app.mainloop();return 0 app=GroundModelApp(read_launch_context());app.mainloop();return 0
if __name__=='__main__':raise SystemExit(main()) if __name__=='__main__':raise SystemExit(main())
@@ -0,0 +1,10 @@
"""Run the existing generator for the in-app dialog, without a native window."""
import sys
from .drawing_job import build_from_files
if __name__ == '__main__':
try:
build_from_files(sys.argv[1], sys.argv[2], lambda message: print(message, flush=True), output=sys.argv[3])
except Exception as error:
print(str(error), file=sys.stderr, flush=True)
sys.exit(1)
+29
View File
@@ -0,0 +1,29 @@
import os,json,unittest,uuid
from pathlib import Path
from unittest.mock import patch
from pierzainer_ground.app import read_launch_context,GroundModelApp,ROOT
class AppLinkTest(unittest.TestCase):
def setUp(self):
self.cache=ROOT/'data/cache'/('gui_link_test_'+uuid.uuid4().hex);self.cache.mkdir(parents=True)
self.excel=ROOT.parent/'도면추출/samples/샘플01_산외교_교각입력데이터.xlsx'
self.drawing=ROOT/'data/input/sanwoe/02.종평면도(울산)_2017.0619-수정.dwg'
def config(self,drawing=''):
return dict(piers=str(self.excel),drawing=drawing,output=str(ROOT/'data/output'/self.cache.name),ready_file=str(self.cache/'ready'))
def test_excel_has_empty_drawing_and_keeps_workbook(self):
file=self.cache/'context.json';file.write_text(json.dumps(self.config()),encoding='utf-8')
with patch.dict(os.environ,PIERZAINER_GROUND_CONTEXT=str(file)):
context=read_launch_context();self.assertEqual(context['drawing'],'');self.assertEqual(context['piers'],str(self.excel))
def test_native_gui_prefills_both_and_calls_existing_pipeline(self):
context=self.config(str(self.drawing));app=GroundModelApp(context);app.withdraw()
try:
self.assertEqual(app.drawing.get(),str(self.drawing));self.assertEqual(app.piers.get(),str(self.excel));self.assertEqual(app.output.get(),context['output']);self.assertEqual(app.run_button['text'],'지반 생성')
with patch('pierzainer_ground.app.build_from_files',return_value={'map_status':{'status':'mapped'}}) as run:
app._start_run();kind,message=app.messages.get(timeout=3);self.assertEqual(kind,'done');self.assertEqual(run.call_args.args[:2],(str(self.drawing),str(self.excel)));self.assertEqual(run.call_args.kwargs['output'],context['output'])
finally:
for token in app.tk.call('after','info'):app.after_cancel(token)
app.destroy()
def test_bad_output_is_rejected(self):
context=self.config();context['output']=str(ROOT.parent);file=self.cache/'context.json';file.write_text(json.dumps(context),encoding='utf-8')
with patch.dict(os.environ,PIERZAINER_GROUND_CONTEXT=str(file)),self.assertRaises(ValueError):read_launch_context()
if __name__=='__main__':unittest.main()
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 01 · 산외교","bridge":"산외교","client":"","author":"","crs":"EPSG:5187","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(inferred_location_checked)."},"source":{"fileName":"샘플01_산외교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":71.0632,"footingBottomEl":42.763,"groundEl":52.099,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":4000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":180993.7987,"y":324479.775,"angleDeg":0},{"name":"IP2","x":180993.0547,"y":324478.3023,"angleDeg":0},{"name":"IP3","x":180988.2522,"y":324468.7966,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":180991.8227,"y":324477.5242,"z":71.4178,"azimuthDeg":296.79913888888893,"row":"전열"},{"no":2,"x":180988.9134,"y":324471.7116,"z":71.2882,"azimuthDeg":296.7994166666667,"row":"전열"},{"no":3,"x":180993.2484,"y":324476.8038,"z":71.4401,"azimuthDeg":296.81624999999997,"row":"후열"},{"no":4,"x":180990.2925,"y":324471.0147,"z":71.3096,"azimuthDeg":296.81597222222223,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":71.0632,"footingBottomEl":42.763,"groundEl":52.099,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":4000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":180993.7987,"y":324479.775,"angleDeg":0},{"name":"IP2","x":180993.0547,"y":324478.3023,"angleDeg":0},{"name":"IP3","x":180988.2522,"y":324468.7966,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":180991.8227,"y":324477.5242,"z":71.4178,"azimuthDeg":296.79913888888893,"row":"전열"},{"no":2,"x":180988.9134,"y":324471.7116,"z":71.2882,"azimuthDeg":296.7994166666667,"row":"전열"},{"no":3,"x":180993.2484,"y":324476.8038,"z":71.4401,"azimuthDeg":296.81624999999997,"row":"후열"},{"no":4,"x":180990.2925,"y":324471.0147,"z":71.3096,"azimuthDeg":296.81597222222223,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":71.0632,"footingBottomEl":42.763,"groundEl":52.099,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":4000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":180993.7987,"y":324479.775,"angleDeg":0},{"name":"IP2","x":180993.0547,"y":324478.3023,"angleDeg":0},{"name":"IP3","x":180988.2522,"y":324468.7966,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":180991.8227,"y":324477.5242,"z":71.4178,"azimuthDeg":296.79913888888893,"row":"전열"},{"no":2,"x":180988.9134,"y":324471.7116,"z":71.2882,"azimuthDeg":296.7994166666667,"row":"전열"},{"no":3,"x":180993.2484,"y":324476.8038,"z":71.4401,"azimuthDeg":296.81624999999997,"row":"후열"},{"no":4,"x":180990.2925,"y":324471.0147,"z":71.3096,"azimuthDeg":296.81597222222223,"row":"후열"}],"bearingAzimuthFrame":"model-xy"}},{"id":"P2","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P2","sheet":"Pier","type":"T형","copingTopEl":71.9164,"footingBottomEl":38.916,"groundEl":41.319,"foundationType":"말뚝기초","pileLengthMm":7000,"copingWidthMm":4000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":181033.8891,"y":324459.3055,"angleDeg":0},{"name":"IP2","x":181033.1327,"y":324457.8391,"angleDeg":0},{"name":"IP3","x":181028.2505,"y":324448.3741,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":181031.8943,"y":324457.0714,"z":72.2629,"azimuthDeg":297.2810833333333,"row":"전열"},{"no":2,"x":181028.9384,"y":324451.2823,"z":72.1334,"azimuthDeg":297.2813611111111,"row":"전열"},{"no":3,"x":181033.3136,"y":324456.3392,"z":72.2932,"azimuthDeg":297.29819444444445,"row":"후열"},{"no":4,"x":181030.3037,"y":324450.578,"z":72.1626,"azimuthDeg":297.29783333333336,Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 02 · 운곡교","bridge":"운곡교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플02_운곡교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":43.411,"footingBottomEl":26.208,"groundEl":28.931,"foundationType":"말뚝기초","pileLengthMm":24400,"copingWidthMm":3500,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":179132.091213,"y":259866.694501,"angleDeg":0},{"name":"IP2","x":179144.095968,"y":259864.658395,"angleDeg":0},{"name":"IP3","x":179144.086088,"y":259864.66007,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":179132.9863,"y":259865.8348,"z":43.625,"azimuthDeg":14.051138888888886,"row":"전열"},{"no":2,"x":179135.4565,"y":259865.4158,"z":43.697,"azimuthDeg":14.046083333333343,"row":"전열"},{"no":3,"x":179137.9267,"y":259864.9969,"z":43.768,"azimuthDeg":14.041055555555545,"row":"전열"},{"no":4,"x":179140.3968,"y":259864.578,"z":43.84,"azimuthDeg":14.036000000000001,"row":"전열"},{"no":5,"x":179142.867,"y":259864.1591,"z":43.912,"azimuthDeg":14.031000000000006,"row":"전열"},{"no":6,"x":179133.2876,"y":259867.0998,"z":43.6,"azimuthDeg":12.74124999999998,"row":"후열"},{"no":7,"x":179135.7577,"y":259866.6809,"z":43.672,"azimuthDeg":12.737694444444458,"row":"후열"},{"no":8,"x":179138.2278,"y":259866.262,"z":43.744,"azimuthDeg":12.734138888888879,"row":"후열"},{"no":9,"x":179140.6978,"y":259865.8431,"z":43.816,"azimuthDeg":12.730611111111102,"row":"후열"},{"no":10,"x":179143.1679,"y":259865.4242,"z":43.887,"azimuthDeg":12.72705555555558,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":43.411,"footingBottomEl":26.208,"groundEl":28.931,"foundationType":"말뚝기초","pileLengthMm":24400,"copingWidthMm":3500,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":179132.091213,"y":259866.694501,"angleDeg":0},{"name":"IP2","x":179144.095968,"y":259864.658395,"angleDeg":0},{"name":"IP3","x":179144.086088,"y":259864.66007,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":179132.9863,"y":259865.8348,"z":43.625,"azimuthDeg":14.051138888888886,"row":"전열"},{"no":2,"x":179135.4565,"y":259865.4158,"z":43.697,"azimuthDeg":14.046083333333343,"row":"전열"},{"no":3,"x":179137.9267,"y":259864.9969,"z":43.768,"azimuthDeg":14.041055555555545,"row":"전열"},{"no":4,"x":179140.3968,"y":259864.578,"z":43.84,"azimuthDeg":14.036000000000001,"row":"전열"},{"no":5,"x":179142.867,"y":259864.1591,"z":43.912,"azimuthDeg":14.031000000000006,"row":"전열"},{"no":6,"x":179133.2876,"y":259867.0998,"z":43.6,"azimuthDeg":12.74124999999998,"row":"후열"},{"no":7,"x":179135.7577,"y":259866.6809,"z":43.672,"azimuthDeg":12.737694444444458,"row":"후열"},{"no":8,"x":179138.2278,"y":259866.262,"z":43.744,"azimuthDeg":12.734138888888879,"row":"후열"},{"no":9,"x":179140.6978,"y":259865.8431,"z":43.816,"azimuthDeg":12.730611111111102,"row":"후열"},{"no":10,"x":179143.1679,"y":259865.4242,"z":43.887,"azimuthDeg":12.72705555555558,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":43.411,"footingBottomEl":26.208,"groundEl":28.931,"foundationType":"말뚝기초","pileLengthMm":24400,"copingWidthMm":3500,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":179132.091213,"y":259866.694501,"angleDeg":0},{"name":"IP2","x":179144.095968,"y":259864.658395,"angleDeg":0},{"name":"IP3","x":179144.086088,"y":259864.66007,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":179132.9863,"y":259865.8348,"z":43.625,"azimuthDeg":14.051138888888886,"row":"전열"},{"no":2,"x":179135.4565,"y":259865.4158,"z":43.697,"azimuthDeg":14.046083333333343,"row":"전열"},{"no":3,"x":179137.9267,"y":259864.9969,"z":43.768,"azimuthDeg":14.041055555555545,"row":"전열"},{"no":4,"x":179140.3968,"y":25986Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 03 · 대호지교","bridge":"대호지교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플03_대호지교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":7.738,"footingBottomEl":1.4,"groundEl":-0.342,"foundationType":"현장타설말뚝","pileLengthMm":29802,"copingWidthMm":3500,"copingLengthMm":24500,"columnCount":2,"ips":[{"name":"IP1","x":153837.294388,"y":480726.410803,"angleDeg":0},{"name":"IP2","x":153833.31775,"y":480714.93,"angleDeg":0},{"name":"IP3","x":153829.341111,"y":480703.449196,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":153836.266,"y":480725.3912,"z":7.8178,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":2,"x":153835.2514,"y":480722.462,"z":7.8798,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":3,"x":153834.2368,"y":480719.5327,"z":7.9418,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":4,"x":153833.2222,"y":480716.6034,"z":8.0038,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":5,"x":153832.2076,"y":480713.6742,"z":8.0038,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":6,"x":153831.193,"y":480710.7449,"z":7.9418,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":7,"x":153830.1783,"y":480707.8157,"z":7.8798,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":8,"x":153829.1637,"y":480704.8864,"z":7.8178,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":9,"x":153837.4944,"y":480724.9657,"z":7.8243,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":10,"x":153836.4798,"y":480722.0365,"z":7.8863,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":11,"x":153835.4652,"y":480719.1072,"z":7.9483,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":12,"x":153834.4506,"y":480716.178,"z":8.0103,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":13,"x":153833.436,"y":480713.2487,"z":8.0103,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":14,"x":153832.4213,"y":480710.3195,"z":7.9483,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":15,"x":153831.4067,"y":480707.3902,"z":7.8863,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":16,"x":153830.3921,"y":480704.4609,"z":7.8243,"azimuthDeg":109.10472222222222,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":7.738,"footingBottomEl":1.4,"groundEl":-0.342,"foundationType":"현장타설말뚝","pileLengthMm":29802,"copingWidthMm":3500,"copingLengthMm":24500,"columnCount":2,"ips":[{"name":"IP1","x":153837.294388,"y":480726.410803,"angleDeg":0},{"name":"IP2","x":153833.31775,"y":480714.93,"angleDeg":0},{"name":"IP3","x":153829.341111,"y":480703.449196,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":153836.266,"y":480725.3912,"z":7.8178,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":2,"x":153835.2514,"y":480722.462,"z":7.8798,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":3,"x":153834.2368,"y":480719.5327,"z":7.9418,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":4,"x":153833.2222,"y":480716.6034,"z":8.0038,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":5,"x":153832.2076,"y":480713.6742,"z":8.0038,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":6,"x":153831.193,"y":480710.7449,"z":7.9418,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":7,"x":153830.1783,"y":480707.8157,"z":7.8798,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":8,"x":153829.1637,"y":480704.8864,"z":7.8178,"azimuthDeg":109.10472222222222,"row":"전열"},{"no":9,"x":153837.4944,"y":480724.9657,"z":7.8243,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":10,"x":153836.4798,"y":480722.0365,"z":7.8863,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":11,"x":153835.4652,"y":480719.1072,"z":7.9483,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":12,"x":153834.4506,"y":480716.178,"z":8.0103,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":13,"x":153833.436,"y":480713.2487,"z":8.0103,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":14,"x":153832.4213,"y":480710.3195,"z":7.9483,"azimuthDeg":109.10472222222222,"row":"후열"},{"no":15,"x":153831.4067,"y":480707.3902,"z":7.8863,"azimuthDeg":109.10472222222222,"Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 04 · 목동교","bridge":"목동교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플04_목동교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":133.8541,"footingBottomEl":110.854,"groundEl":118.79,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3500,"copingLengthMm":16500,"columnCount":2,"ips":[{"name":"IP1","x":218127.628104,"y":531065.25932,"angleDeg":0},{"name":"IP2","x":218138.896901,"y":531074.320997,"angleDeg":0},{"name":"IP3","x":218140.182759,"y":531075.355006,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":218129.3712,"y":531065.8269,"z":133.9411,"azimuthDeg":321.06191666666666,"row":"전열"},{"no":2,"x":218131.265,"y":531067.3497,"z":133.9896,"azimuthDeg":321.0619722222222,"row":"전열"},{"no":3,"x":218133.3535,"y":531069.0291,"z":134.043,"azimuthDeg":321.06202777777776,"row":"전열"},{"no":4,"x":218135.4421,"y":531070.7086,"z":134.0965,"azimuthDeg":321.06208333333336,"row":"전열"},{"no":5,"x":218137.5306,"y":531072.3881,"z":134.15,"azimuthDeg":321.06213888888885,"row":"전열"},{"no":6,"x":218139.4243,"y":531073.9109,"z":134.1985,"azimuthDeg":321.06219444444446,"row":"전열"},{"no":7,"x":218128.5503,"y":531066.8348,"z":133.953,"azimuthDeg":320.60875,"row":"후열"},{"no":8,"x":218130.4438,"y":531068.3578,"z":134.0015,"azimuthDeg":320.60897222222223,"row":"후열"},{"no":9,"x":218132.5324,"y":531070.0373,"z":134.055,"azimuthDeg":320.60922222222223,"row":"후열"},{"no":10,"x":218134.6209,"y":531071.7168,"z":134.1084,"azimuthDeg":320.6094444444444,"row":"후열"},{"no":11,"x":218136.7095,"y":531073.3962,"z":134.1619,"azimuthDeg":320.60966666666667,"row":"후열"},{"no":12,"x":218138.6034,"y":531074.9188,"z":134.2103,"azimuthDeg":320.6098888888889,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":133.8541,"footingBottomEl":110.854,"groundEl":118.79,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3500,"copingLengthMm":16500,"columnCount":2,"ips":[{"name":"IP1","x":218127.628104,"y":531065.25932,"angleDeg":0},{"name":"IP2","x":218138.896901,"y":531074.320997,"angleDeg":0},{"name":"IP3","x":218140.182759,"y":531075.355006,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":218129.3712,"y":531065.8269,"z":133.9411,"azimuthDeg":321.06191666666666,"row":"전열"},{"no":2,"x":218131.265,"y":531067.3497,"z":133.9896,"azimuthDeg":321.0619722222222,"row":"전열"},{"no":3,"x":218133.3535,"y":531069.0291,"z":134.043,"azimuthDeg":321.06202777777776,"row":"전열"},{"no":4,"x":218135.4421,"y":531070.7086,"z":134.0965,"azimuthDeg":321.06208333333336,"row":"전열"},{"no":5,"x":218137.5306,"y":531072.3881,"z":134.15,"azimuthDeg":321.06213888888885,"row":"전열"},{"no":6,"x":218139.4243,"y":531073.9109,"z":134.1985,"azimuthDeg":321.06219444444446,"row":"전열"},{"no":7,"x":218128.5503,"y":531066.8348,"z":133.953,"azimuthDeg":320.60875,"row":"후열"},{"no":8,"x":218130.4438,"y":531068.3578,"z":134.0015,"azimuthDeg":320.60897222222223,"row":"후열"},{"no":9,"x":218132.5324,"y":531070.0373,"z":134.055,"azimuthDeg":320.60922222222223,"row":"후열"},{"no":10,"x":218134.6209,"y":531071.7168,"z":134.1084,"azimuthDeg":320.6094444444444,"row":"후열"},{"no":11,"x":218136.7095,"y":531073.3962,"z":134.1619,"azimuthDeg":320.60966666666667,"row":"후열"},{"no":12,"x":218138.6034,"y":531074.9188,"z":134.2103,"azimuthDeg":320.6098888888889,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":133.8541,"footingBottomEl":110.854,"groundEl":118.79,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3500,"copingLengthMm":16500,"columnCount":2,"ips":[{"name":"IP1","x":218127.628104,"y":531065.25932,"angleDeg":0},{"name":"IP2","x":218138.896901,"y":531074.320997,"angleDeg":0},{"name":"IP3"Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 05 · 승천천교","bridge":"승천천교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플05_승천천교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[],"history":[{"version":"sample-20261102","note":"교각 Excel과 원도면 지반·위성사진·2D 지도 통합. 좌표·스키마·형상 검증.","at":"2026-11-02T14:34:50.364983"}],"bearingBasis":"excel","groundModel":{"schema":2,"generated":"2026-11-02","piers":[{"id":"P1","centerEl":55,"excelEl":null}],"terrainGrid":{"origin":[458809.512,224222.818],"step":5,"nx":41,"ny":41,"z":[null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55.322,55.896,55.661,55.11,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55.689,56.161,55.677,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55.78,55.857,55.371,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55.009,55.119,55.196,55.273,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,null,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,55,null,null,null,nullLine truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 06 · 계문철교","bridge":"계문철교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플06_계문철교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":18.5041,"footingBottomEl":4.804,"groundEl":7.06,"foundationType":"말뚝기초","pileLengthMm":21000,"copingWidthMm":3500,"copingLengthMm":21000,"columnCount":2,"ips":[{"name":"IP1","x":194908.7711,"y":376920.628019,"angleDeg":0},{"name":"IP2","x":194909.621705,"y":376910.212695,"angleDeg":0},{"name":"IP3","x":194910.472309,"y":376899.797371,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":194908.2982,"y":376919.5055,"z":19.6384,"azimuthDeg":83.45638888888891,"row":"전열"},{"no":2,"x":194908.5459,"y":376916.4756,"z":19.4854,"azimuthDeg":83.447,"row":"전열"},{"no":3,"x":194908.7936,"y":376913.4458,"z":19.3333,"azimuthDeg":83.43755555555555,"row":"전열"},{"no":4,"x":194909.0413,"y":376910.4159,"z":19.1822,"azimuthDeg":83.42797222222225,"row":"전열"},{"no":5,"x":194909.2891,"y":376907.386,"z":19.0292,"azimuthDeg":83.41830555555555,"row":"전열"},{"no":6,"x":194909.5368,"y":376904.3561,"z":18.8771,"azimuthDeg":83.40855555555555,"row":"전열"},{"no":7,"x":194909.7845,"y":376901.3262,"z":18.7251,"azimuthDeg":83.39869444444446,"row":"전열"},{"no":8,"x":194909.4601,"y":376919.6004,"z":19.6539,"azimuthDeg":87.6826388888889,"row":"후열"},{"no":9,"x":194909.7075,"y":376916.5705,"z":19.5019,"azimuthDeg":87.69441666666665,"row":"후열"},{"no":10,"x":194909.9548,"y":376913.5406,"z":19.3499,"azimuthDeg":87.7063333333333,"row":"후열"},{"no":11,"x":194910.2022,"y":376910.5106,"z":19.1979,"azimuthDeg":87.71836111111111,"row":"후열"},{"no":12,"x":194910.4496,"y":376907.4807,"z":19.0459,"azimuthDeg":87.7305,"row":"후열"},{"no":13,"x":194910.6969,"y":376904.4508,"z":18.8939,"azimuthDeg":87.74277777777775,"row":"후열"},{"no":14,"x":194910.9443,"y":376901.4209,"z":18.7419,"azimuthDeg":87.7551666666667,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":18.5041,"footingBottomEl":4.804,"groundEl":7.06,"foundationType":"말뚝기초","pileLengthMm":21000,"copingWidthMm":3500,"copingLengthMm":21000,"columnCount":2,"ips":[{"name":"IP1","x":194908.7711,"y":376920.628019,"angleDeg":0},{"name":"IP2","x":194909.621705,"y":376910.212695,"angleDeg":0},{"name":"IP3","x":194910.472309,"y":376899.797371,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":194908.2982,"y":376919.5055,"z":19.6384,"azimuthDeg":83.45638888888891,"row":"전열"},{"no":2,"x":194908.5459,"y":376916.4756,"z":19.4854,"azimuthDeg":83.447,"row":"전열"},{"no":3,"x":194908.7936,"y":376913.4458,"z":19.3333,"azimuthDeg":83.43755555555555,"row":"전열"},{"no":4,"x":194909.0413,"y":376910.4159,"z":19.1822,"azimuthDeg":83.42797222222225,"row":"전열"},{"no":5,"x":194909.2891,"y":376907.386,"z":19.0292,"azimuthDeg":83.41830555555555,"row":"전열"},{"no":6,"x":194909.5368,"y":376904.3561,"z":18.8771,"azimuthDeg":83.40855555555555,"row":"전열"},{"no":7,"x":194909.7845,"y":376901.3262,"z":18.7251,"azimuthDeg":83.39869444444446,"row":"전열"},{"no":8,"x":194909.4601,"y":376919.6004,"z":19.6539,"azimuthDeg":87.6826388888889,"row":"후열"},{"no":9,"x":194909.7075,"y":376916.5705,"z":19.5019,"azimuthDeg":87.69441666666665,"row":"후열"},{"no":10,"x":194909.9548,"y":376913.5406,"z":19.3499,"azimuthDeg":87.7063333333333,"row":"후열"},{"no":11,"x":194910.2022,"y":376910.5106,"z":19.1979,"azimuthDeg":87.71836111111111,"row":"후열"},{"no":12,"x":194910.4496,"y":376907.4807,"z":19.0459,"azimuthDeg":87.7305,"row":"후열"},{"no":13,"x":194910.6969,"y":376904.4508,"z":18.8939,"azimuthDeg":87.74277777777775,"row":"후열"},{"no":14,"x":194910.9443,"y":376901.4209,"z":18.7419,"azimuthDeg":87.7551666666667,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"PieLine truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 07 · 기양천교","bridge":"기양천교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플07_기양천교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":15.7665,"footingBottomEl":0.966,"groundEl":5.97,"foundationType":"말뚝기초","pileLengthMm":13600,"copingWidthMm":3500,"copingLengthMm":22500,"columnCount":2,"ips":[{"name":"IP1","x":195901.933872,"y":376676.684353,"angleDeg":0},{"name":"IP2","x":195904.750042,"y":376666.017388,"angleDeg":0},{"name":"IP3","x":195907.564243,"y":376655.357883,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":195901.7932,"y":376675.2306,"z":15.9498,"azimuthDeg":94.7138333333333,"row":"전열"},{"no":2,"x":195902.6065,"y":376672.1472,"z":16.0318,"azimuthDeg":94.67783333333335,"row":"전열"},{"no":3,"x":195903.4195,"y":376669.0643,"z":16.1134,"azimuthDeg":94.642,"row":"전열"},{"no":4,"x":195904.2324,"y":376665.9822,"z":16.1956,"azimuthDeg":94.60630555555554,"row":"전열"},{"no":5,"x":195905.045,"y":376662.9006,"z":16.2764,"azimuthDeg":94.57072222222223,"row":"전열"},{"no":6,"x":195905.8574,"y":376659.8197,"z":16.3568,"azimuthDeg":94.53527777777776,"row":"전열"},{"no":7,"x":195906.6696,"y":376656.7394,"z":16.4367,"azimuthDeg":94.4999722222222,"row":"전열"},{"no":8,"x":195902.9584,"y":376675.1501,"z":15.9811,"azimuthDeg":93.19136111111112,"row":"후열"},{"no":9,"x":195903.7717,"y":376672.0673,"z":16.0627,"azimuthDeg":93.15808333333337,"row":"후열"},{"no":10,"x":195904.5848,"y":376668.9852,"z":16.1438,"azimuthDeg":93.12494444444445,"row":"후열"},{"no":11,"x":195905.3977,"y":376665.9037,"z":16.2256,"azimuthDeg":93.09191666666669,"row":"후열"},{"no":12,"x":195906.2105,"y":376662.8229,"z":16.3059,"azimuthDeg":93.05902777777777,"row":"후열"},{"no":13,"x":195907.0231,"y":376659.7426,"z":16.3859,"azimuthDeg":93.02625,"row":"후열"},{"no":14,"x":195907.8355,"y":376656.663,"z":16.4654,"azimuthDeg":92.99358333333333,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":15.7665,"footingBottomEl":0.966,"groundEl":5.97,"foundationType":"말뚝기초","pileLengthMm":13600,"copingWidthMm":3500,"copingLengthMm":22500,"columnCount":2,"ips":[{"name":"IP1","x":195901.933872,"y":376676.684353,"angleDeg":0},{"name":"IP2","x":195904.750042,"y":376666.017388,"angleDeg":0},{"name":"IP3","x":195907.564243,"y":376655.357883,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":195901.7932,"y":376675.2306,"z":15.9498,"azimuthDeg":94.7138333333333,"row":"전열"},{"no":2,"x":195902.6065,"y":376672.1472,"z":16.0318,"azimuthDeg":94.67783333333335,"row":"전열"},{"no":3,"x":195903.4195,"y":376669.0643,"z":16.1134,"azimuthDeg":94.642,"row":"전열"},{"no":4,"x":195904.2324,"y":376665.9822,"z":16.1956,"azimuthDeg":94.60630555555554,"row":"전열"},{"no":5,"x":195905.045,"y":376662.9006,"z":16.2764,"azimuthDeg":94.57072222222223,"row":"전열"},{"no":6,"x":195905.8574,"y":376659.8197,"z":16.3568,"azimuthDeg":94.53527777777776,"row":"전열"},{"no":7,"x":195906.6696,"y":376656.7394,"z":16.4367,"azimuthDeg":94.4999722222222,"row":"전열"},{"no":8,"x":195902.9584,"y":376675.1501,"z":15.9811,"azimuthDeg":93.19136111111112,"row":"후열"},{"no":9,"x":195903.7717,"y":376672.0673,"z":16.0627,"azimuthDeg":93.15808333333337,"row":"후열"},{"no":10,"x":195904.5848,"y":376668.9852,"z":16.1438,"azimuthDeg":93.12494444444445,"row":"후열"},{"no":11,"x":195905.3977,"y":376665.9037,"z":16.2256,"azimuthDeg":93.09191666666669,"row":"후열"},{"no":12,"x":195906.2105,"y":376662.8229,"z":16.3059,"azimuthDeg":93.05902777777777,"row":"후열"},{"no":13,"x":195907.0231,"y":376659.7426,"z":16.3859,"azimuthDeg":93.02625,"row":"후열"},{"no":14,"x":195907.8355,"y":376656.663,"z":16.4654,"azimuthDeg":92.99358333333333,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheetLine truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 08 · 옥성교","bridge":"옥성교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플08_옥성교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":21.1279,"footingBottomEl":12.175,"groundEl":18.09,"foundationType":"말뚝기초","pileLengthMm":22000,"copingWidthMm":3500,"copingLengthMm":13500,"columnCount":1,"ips":[{"name":"IP1","x":207605.780013,"y":429978.461366,"angleDeg":0},{"name":"IP2","x":207599.646855,"y":429976.251707,"angleDeg":0},{"name":"IP3","x":207593.513698,"y":429974.042048,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":207604.1324,"y":429978.4314,"z":21.3981,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":2,"x":207601.8028,"y":429977.5921,"z":21.4317,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":3,"x":207599.4732,"y":429976.7528,"z":21.4653,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":4,"x":207597.1436,"y":429975.9134,"z":21.4115,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":5,"x":207594.814,"y":429975.0741,"z":21.3509,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":6,"x":207604.868,"y":429977.4835,"z":21.4193,"azimuthDeg":142.18688888888886,"row":"후열"},{"no":7,"x":207602.5384,"y":429976.6442,"z":21.4529,"azimuthDeg":142.18688888888886,"row":"후열"},{"no":8,"x":207600.2088,"y":429975.8048,"z":21.4865,"azimuthDeg":142.18688888888886,"row":"후열"},{"no":9,"x":207597.8792,"y":429974.9655,"z":21.4326,"azimuthDeg":142.18688888888886,"row":"후열"},{"no":10,"x":207595.5496,"y":429974.1262,"z":21.372,"azimuthDeg":142.18688888888886,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":21.1279,"footingBottomEl":12.175,"groundEl":18.09,"foundationType":"말뚝기초","pileLengthMm":22000,"copingWidthMm":3500,"copingLengthMm":13500,"columnCount":1,"ips":[{"name":"IP1","x":207605.780013,"y":429978.461366,"angleDeg":0},{"name":"IP2","x":207599.646855,"y":429976.251707,"angleDeg":0},{"name":"IP3","x":207593.513698,"y":429974.042048,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":207604.1324,"y":429978.4314,"z":21.3981,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":2,"x":207601.8028,"y":429977.5921,"z":21.4317,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":3,"x":207599.4732,"y":429976.7528,"z":21.4653,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":4,"x":207597.1436,"y":429975.9134,"z":21.4115,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":5,"x":207594.814,"y":429975.0741,"z":21.3509,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":6,"x":207604.868,"y":429977.4835,"z":21.4193,"azimuthDeg":142.18688888888886,"row":"후열"},{"no":7,"x":207602.5384,"y":429976.6442,"z":21.4529,"azimuthDeg":142.18688888888886,"row":"후열"},{"no":8,"x":207600.2088,"y":429975.8048,"z":21.4865,"azimuthDeg":142.18688888888886,"row":"후열"},{"no":9,"x":207597.8792,"y":429974.9655,"z":21.4326,"azimuthDeg":142.18688888888886,"row":"후열"},{"no":10,"x":207595.5496,"y":429974.1262,"z":21.372,"azimuthDeg":142.18688888888886,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":21.1279,"footingBottomEl":12.175,"groundEl":18.09,"foundationType":"말뚝기초","pileLengthMm":22000,"copingWidthMm":3500,"copingLengthMm":13500,"columnCount":1,"ips":[{"name":"IP1","x":207605.780013,"y":429978.461366,"angleDeg":0},{"name":"IP2","x":207599.646855,"y":429976.251707,"angleDeg":0},{"name":"IP3","x":207593.513698,"y":429974.042048,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":207604.1324,"y":429978.4314,"z":21.3981,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":2,"x":207601.8028,"y":429977.5921,"z":21.4317,"azimuthDeg":142.18688888888886,"row":"전열"},{"no":3,"x":207599.4732,"y":429976.7528,"z":21.4653,"azimuthDeg":142.18688888888886,"row":"전열Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 09 · 새재1교","bridge":"새재1교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플09_새재1교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":32.5316,"footingBottomEl":10.032,"groundEl":16.95,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":208547.804218,"y":428110.699674,"angleDeg":0},{"name":"IP2","x":208542.956234,"y":428106.806474,"angleDeg":0},{"name":"IP3","x":208538.108532,"y":428102.913503,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":208546.25,"y":428110.0923,"z":32.7901,"azimuthDeg":139.06241666666665,"row":"전열"},{"no":2,"x":208544.2952,"y":428108.5226,"z":32.8911,"azimuthDeg":139.07152777777776,"row":"전열"},{"no":3,"x":208542.3406,"y":428106.9529,"z":32.9921,"azimuthDeg":139.08055555555552,"row":"전열"},{"no":4,"x":208540.3859,"y":428105.3832,"z":33.0931,"azimuthDeg":139.0895277777778,"row":"전열"},{"no":5,"x":208538.4314,"y":428103.8136,"z":33.1941,"azimuthDeg":139.0984166666667,"row":"전열"},{"no":6,"x":208546.9329,"y":428109.3626,"z":32.7757,"azimuthDeg":134.73080555555555,"row":"후열"},{"no":7,"x":208544.978,"y":428107.7926,"z":32.8767,"azimuthDeg":134.75805555555553,"row":"후열"},{"no":8,"x":208543.0231,"y":428106.2227,"z":32.9777,"azimuthDeg":134.78505555555557,"row":"후열"},{"no":9,"x":208541.0682,"y":428104.6528,"z":33.0787,"azimuthDeg":134.8118333333333,"row":"후열"},{"no":10,"x":208539.1134,"y":428103.083,"z":33.1797,"azimuthDeg":134.83838888888891,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":32.5316,"footingBottomEl":10.032,"groundEl":16.95,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":208547.804218,"y":428110.699674,"angleDeg":0},{"name":"IP2","x":208542.956234,"y":428106.806474,"angleDeg":0},{"name":"IP3","x":208538.108532,"y":428102.913503,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":208546.25,"y":428110.0923,"z":32.7901,"azimuthDeg":139.06241666666665,"row":"전열"},{"no":2,"x":208544.2952,"y":428108.5226,"z":32.8911,"azimuthDeg":139.07152777777776,"row":"전열"},{"no":3,"x":208542.3406,"y":428106.9529,"z":32.9921,"azimuthDeg":139.08055555555552,"row":"전열"},{"no":4,"x":208540.3859,"y":428105.3832,"z":33.0931,"azimuthDeg":139.0895277777778,"row":"전열"},{"no":5,"x":208538.4314,"y":428103.8136,"z":33.1941,"azimuthDeg":139.0984166666667,"row":"전열"},{"no":6,"x":208546.9329,"y":428109.3626,"z":32.7757,"azimuthDeg":134.73080555555555,"row":"후열"},{"no":7,"x":208544.978,"y":428107.7926,"z":32.8767,"azimuthDeg":134.75805555555553,"row":"후열"},{"no":8,"x":208543.0231,"y":428106.2227,"z":32.9777,"azimuthDeg":134.78505555555557,"row":"후열"},{"no":9,"x":208541.0682,"y":428104.6528,"z":33.0787,"azimuthDeg":134.8118333333333,"row":"후열"},{"no":10,"x":208539.1134,"y":428103.083,"z":33.1797,"azimuthDeg":134.83838888888891,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":32.5316,"footingBottomEl":10.032,"groundEl":16.95,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":208547.804218,"y":428110.699674,"angleDeg":0},{"name":"IP2","x":208542.956234,"y":428106.806474,"angleDeg":0},{"name":"IP3","x":208538.108532,"y":428102.913503,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":208546.25,"y":428110.0923,"z":32.7901,"azimuthDeg":139.06241666666665,"row":"전열"},{"no":2,"x":208544.2952,"y":428108.5226,"z":32.8911,"azimuthDeg":139.07152777777776,"row":"전열"},{"no":3,"x":208542.3406,"y":428106.9529,"z":32.9921,"azimuthDeg":139.08055555555552,"row":"전열"},{"no":4,"x":2085Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 10 · 도하4교","bridge":"도하4교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플10_도하4교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":98.552,"footingBottomEl":78.852,"groundEl":85.365,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":4000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":200987.223238,"y":583704.910196,"angleDeg":0},{"name":"IP2","x":200987.580804,"y":583703.299406,"angleDeg":0},{"name":"IP3","x":200989.888734,"y":583692.902485,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":200987.095,"y":583702.013,"z":98.859,"azimuthDeg":77.47163888888889,"row":"전열"},{"no":2,"x":200988.549,"y":583695.473,"z":98.725,"azimuthDeg":77.47163888888889,"row":"전열"},{"no":3,"x":200988.565,"y":583702.34,"z":98.847,"azimuthDeg":77.49702777777776,"row":"후열"},{"no":4,"x":200990.015,"y":583695.798,"z":98.713,"azimuthDeg":77.49700000000001,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":98.552,"footingBottomEl":78.852,"groundEl":85.365,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":4000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":200987.223238,"y":583704.910196,"angleDeg":0},{"name":"IP2","x":200987.580804,"y":583703.299406,"angleDeg":0},{"name":"IP3","x":200989.888734,"y":583692.902485,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":200987.095,"y":583702.013,"z":98.859,"azimuthDeg":77.47163888888889,"row":"전열"},{"no":2,"x":200988.549,"y":583695.473,"z":98.725,"azimuthDeg":77.47163888888889,"row":"전열"},{"no":3,"x":200988.565,"y":583702.34,"z":98.847,"azimuthDeg":77.49702777777776,"row":"후열"},{"no":4,"x":200990.015,"y":583695.798,"z":98.713,"azimuthDeg":77.49700000000001,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"T형","copingTopEl":98.552,"footingBottomEl":78.852,"groundEl":85.365,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":4000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":200987.223238,"y":583704.910196,"angleDeg":0},{"name":"IP2","x":200987.580804,"y":583703.299406,"angleDeg":0},{"name":"IP3","x":200989.888734,"y":583692.902485,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":200987.095,"y":583702.013,"z":98.859,"azimuthDeg":77.47163888888889,"row":"전열"},{"no":2,"x":200988.549,"y":583695.473,"z":98.725,"azimuthDeg":77.47163888888889,"row":"전열"},{"no":3,"x":200988.565,"y":583702.34,"z":98.847,"azimuthDeg":77.49702777777776,"row":"후열"},{"no":4,"x":200990.015,"y":583695.798,"z":98.713,"azimuthDeg":77.49700000000001,"row":"후열"}],"bearingAzimuthFrame":"model-xy"}},{"id":"P2","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P2","sheet":"Pier","type":"T형","copingTopEl":98.108,"footingBottomEl":78.408,"groundEl":85.754,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":4000,"copingLengthMm":12500,"columnCount":1,"ips":[{"name":"IP1","x":201041.036417,"y":583716.399861,"angleDeg":0},{"name":"IP2","x":201041.367881,"y":583714.783498,"angleDeg":0},{"name":"IP3","x":201043.507331,"y":583704.350605,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":201040.861,"y":583713.505,"z":98.415,"azimuthDeg":78.39847222222221,"row":"전열"},{"no":2,"x":201042.209,"y":583706.942,"z":98.281,"azimuthDeg":78.39847222222221,"row":"전열"},{"no":3,"x":201042.336,"y":583713.808,"z":98.402,"azimuthDeg":78.42386111111114,"row":"후열"},{"no":4,"x":201043.68,"y":583707.244,"z":98.268,"azimuthDeg":78.42383333333333,"row"Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 11 · 곡천교","bridge":"곡천교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(project_default_not_source_verified)."},"source":{"fileName":"샘플11_곡천교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":86.8393,"footingBottomEl":61.44,"groundEl":64.692,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3500,"copingLengthMm":17000,"columnCount":2,"ips":[{"name":"IP1","x":226998.64978,"y":492646.179517,"angleDeg":0},{"name":"IP2","x":226998.640605,"y":492646.174649,"angleDeg":0},{"name":"IP3","x":227013.515037,"y":492654.066886,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":226999.795,"y":492646.0899,"z":87.3778,"azimuthDeg":347.36733333333336,"row":"전열"},{"no":2,"x":227002.2906,"y":492647.414,"z":87.2989,"azimuthDeg":347.3780277777778,"row":"전열"},{"no":3,"x":227004.7862,"y":492648.7381,"z":87.2201,"azimuthDeg":347.38875,"row":"전열"},{"no":4,"x":227007.282,"y":492650.0623,"z":87.1413,"azimuthDeg":347.39947222222224,"row":"전열"},{"no":5,"x":227009.7779,"y":492651.3866,"z":87.0623,"azimuthDeg":347.4102222222222,"row":"전열"},{"no":6,"x":227012.2739,"y":492652.7109,"z":86.9834,"azimuthDeg":347.421,"row":"전열"},{"no":7,"x":226999.5196,"y":492647.3558,"z":87.3832,"azimuthDeg":348.11983333333336,"row":"후열"},{"no":8,"x":227002.0154,"y":492648.6799,"z":87.3043,"azimuthDeg":348.1310555555556,"row":"후열"},{"no":9,"x":227004.5113,"y":492650.0041,"z":87.2254,"azimuthDeg":348.1422777777778,"row":"후열"},{"no":10,"x":227007.0073,"y":492651.3283,"z":87.1466,"azimuthDeg":348.15352777777775,"row":"후열"},{"no":11,"x":227009.5035,"y":492652.6527,"z":87.0676,"azimuthDeg":348.1647777777778,"row":"후열"},{"no":12,"x":227011.9998,"y":492653.9771,"z":86.9886,"azimuthDeg":348.17605555555554,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":86.8393,"footingBottomEl":61.44,"groundEl":64.692,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3500,"copingLengthMm":17000,"columnCount":2,"ips":[{"name":"IP1","x":226998.64978,"y":492646.179517,"angleDeg":0},{"name":"IP2","x":226998.640605,"y":492646.174649,"angleDeg":0},{"name":"IP3","x":227013.515037,"y":492654.066886,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":226999.795,"y":492646.0899,"z":87.3778,"azimuthDeg":347.36733333333336,"row":"전열"},{"no":2,"x":227002.2906,"y":492647.414,"z":87.2989,"azimuthDeg":347.3780277777778,"row":"전열"},{"no":3,"x":227004.7862,"y":492648.7381,"z":87.2201,"azimuthDeg":347.38875,"row":"전열"},{"no":4,"x":227007.282,"y":492650.0623,"z":87.1413,"azimuthDeg":347.39947222222224,"row":"전열"},{"no":5,"x":227009.7779,"y":492651.3866,"z":87.0623,"azimuthDeg":347.4102222222222,"row":"전열"},{"no":6,"x":227012.2739,"y":492652.7109,"z":86.9834,"azimuthDeg":347.421,"row":"전열"},{"no":7,"x":226999.5196,"y":492647.3558,"z":87.3832,"azimuthDeg":348.11983333333336,"row":"후열"},{"no":8,"x":227002.0154,"y":492648.6799,"z":87.3043,"azimuthDeg":348.1310555555556,"row":"후열"},{"no":9,"x":227004.5113,"y":492650.0041,"z":87.2254,"azimuthDeg":348.1422777777778,"row":"후열"},{"no":10,"x":227007.0073,"y":492651.3283,"z":87.1466,"azimuthDeg":348.15352777777775,"row":"후열"},{"no":11,"x":227009.5035,"y":492652.6527,"z":87.0676,"azimuthDeg":348.1647777777778,"row":"후열"},{"no":12,"x":227011.9998,"y":492653.9771,"z":86.9886,"azimuthDeg":348.17605555555554,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":86.8393,"footingBottomEl":61.44,"groundEl":64.692,"foundationType":"직접기초","pileLengthMm":0,"copingWidthMm":3500,"copingLengthMm":17000,"columnCount":2,"ips":[{"name":"IP1","x":226998.64978,"y":492646.179517,"angleDeg":0},{"name":"IP2","x":226998.640605,"y":492646.174649,"angleDeg":0},{"name":"IP3","x":227013.515037,"y":492654.066886,"angleDeg":0},{"Line truncated
@@ -0,0 +1 @@
{"schemaVersion":1,"project":{"name":"샘플 12 · 감북교","bridge":"감북교","client":"","author":"","crs":"EPSG:5186","memo":"도면추출 연동 샘플 + 원도면 지반. 설계 지반고 유지. 지도 CRS는 원자료 확인이 필요한 가정(inferred_false_northing_convention_not_source_verified)."},"source":{"fileName":"샘플12_감북교_교각입력데이터.xlsx","sheets":["Pier","IP","Bearing"]},"piers":[{"id":"P1","input":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":56.5998,"footingBottomEl":43.604,"groundEl":44.64,"foundationType":"현장타설말뚝","pileLengthMm":17566,"copingWidthMm":3000,"copingLengthMm":20000,"columnCount":2,"ips":[{"name":"IP1","x":214529.86606,"y":547194.678176,"angleDeg":0},{"name":"IP2","x":214548.146791,"y":547202.413639,"angleDeg":0},{"name":"IP3","x":214548.137582,"y":547202.409742,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":214531.1568,"y":547194.6814,"z":56.8196,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":2,"x":214533.8276,"y":547195.8116,"z":56.8776,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":3,"x":214536.4983,"y":547196.9417,"z":56.9356,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":4,"x":214539.169,"y":547198.0718,"z":56.9936,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":5,"x":214541.8398,"y":547199.2019,"z":57.0516,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":6,"x":214544.5105,"y":547200.332,"z":57.1096,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":7,"x":214547.1812,"y":547201.4622,"z":57.1676,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":8,"x":214530.7671,"y":547195.6024,"z":56.7998,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":9,"x":214533.4379,"y":547196.7325,"z":56.8578,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":10,"x":214536.1086,"y":547197.8626,"z":56.9158,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":11,"x":214538.7793,"y":547198.9927,"z":56.9738,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":12,"x":214541.4501,"y":547200.1229,"z":57.0318,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":13,"x":214544.1208,"y":547201.253,"z":57.0898,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":14,"x":214546.7916,"y":547202.3831,"z":57.1478,"azimuthDeg":337.06444444444446,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"defaults":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"pileThicknessMm":12,"copingH1Mm":1000,"copingH2Mm":1500,"levelWidthMm":2100,"name":"P1","sheet":"Pier","type":"2주형","copingTopEl":56.5998,"footingBottomEl":43.604,"groundEl":44.64,"foundationType":"현장타설말뚝","pileLengthMm":17566,"copingWidthMm":3000,"copingLengthMm":20000,"columnCount":2,"ips":[{"name":"IP1","x":214529.86606,"y":547194.678176,"angleDeg":0},{"name":"IP2","x":214548.146791,"y":547202.413639,"angleDeg":0},{"name":"IP3","x":214548.137582,"y":547202.409742,"angleDeg":0},{"name":"IP4","x":0,"y":0,"angleDeg":0}],"bearings":[{"no":1,"x":214531.1568,"y":547194.6814,"z":56.8196,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":2,"x":214533.8276,"y":547195.8116,"z":56.8776,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":3,"x":214536.4983,"y":547196.9417,"z":56.9356,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":4,"x":214539.169,"y":547198.0718,"z":56.9936,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":5,"x":214541.8398,"y":547199.2019,"z":57.0516,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":6,"x":214544.5105,"y":547200.332,"z":57.1096,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":7,"x":214547.1812,"y":547201.4622,"z":57.1676,"azimuthDeg":337.06444444444446,"row":"전열"},{"no":8,"x":214530.7671,"y":547195.6024,"z":56.7998,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":9,"x":214533.4379,"y":547196.7325,"z":56.8578,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":10,"x":214536.1086,"y":547197.8626,"z":56.9158,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":11,"x":214538.7793,"y":547198.9927,"z":56.9738,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":12,"x":214541.4501,"y":547200.1229,"z":57.0318,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":13,"x":214544.1208,"y":547201.253,"z":57.0898,"azimuthDeg":337.06444444444446,"row":"후열"},{"no":14,"x":214546.7916,"y":547202.3831,"z":57.1478,"azimuthDeg":337.06444444444446,"row":"후열"}],"bearingAzimuthFrame":"model-xy"},"draft":{"columnDiameterMm":2000,"footingLengthMm":10400,"footingWidthMm":8150,"footingHeightMm":2000,"blindingMarginMm":100,"blindingThicknessMm":100,"pileKind":"강관","pileOuterMm":609.6,"Line truncated
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
# Headless Chrome screenshot of the sample page (WSL2 → Windows Chrome).
# Usage: scripts/capture-sample.sh <output.png> [url]
# Requires the dev server: npm run dev -- --host 127.0.0.1 --port 5173
set -euo pipefail
OUT_WSL="${1:?output png path (WSL path)}"
URL="${2:-http://127.0.0.1:5173/?sample=1}"
CHROME="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
mkdir -p "$(dirname "$OUT_WSL")"
timeout 200 "$CHROME" --headless=new --no-sandbox --use-gl=angle --use-angle=swiftshader \
--enable-unsafe-swiftshader --enable-logging=stderr --v=0 --window-size=1600,1000 \
--timeout=45000 --screenshot="$(wslpath -w "$OUT_WSL")" "$URL"
+26
View File
@@ -0,0 +1,26 @@
// Read-only diagnosis: concrete scene vs rebar paths for every pier/foundation combination.
import { createServer } from 'vite';
import { mkdir, writeFile } from 'node:fs/promises';
const server = await createServer({ configFile: false, server: { middlewareMode: true, hmr: false, watch: null }, appType: 'custom' });
try {
const { loadP1 } = await server.ssrLoadModule('/tests/helpers.ts');
const { buildPier } = await server.ssrLoadModule('/src/core/geometry.ts');
const { buildPierScene } = await server.ssrLoadModule('/src/view/scene.ts');
const { buildRebar3D } = await server.ssrLoadModule('/src/view/rebar3d.ts');
const { rebarSummary } = await server.ssrLoadModule('/src/core/rebar/summary.ts');
const result = [];
for (const [type, columnCount] of [['T형', 1], ['2주형', 2], ['3주형', 3]]) {
for (const foundationType of ['직접기초', '말뚝기초', '현장타설말뚝']) {
const model = buildPier({ ...loadP1(), type, columnCount, foundationType });
const record = { type, foundationType, columns: model.columns.length, footings: model.footings.length, piles: model.piles.length };
for (const [name, run] of Object.entries({ concrete: () => buildPierScene(model), rebar3d: () => buildRebar3D(model), summary: () => rebarSummary(model) })) {
try { run(); record[name] = { ok: true }; }
catch (error) { record[name] = { ok: false, message: error.message, stack: error.stack.split('\n').slice(0, 5) }; }
}
result.push(record);
}
}
await mkdir('.verify/cast-in-place-20261105', { recursive: true });
await writeFile('.verify/cast-in-place-20261105/diagnosis.json', JSON.stringify(result, null, 2));
console.table(result.map(({ type, foundationType, concrete, rebar3d, summary }) => ({ type, foundationType, concrete: concrete.ok, rebar3d: rebar3d.ok, summary: summary.ok })));
} finally { await server.close(); }
+22
View File
@@ -0,0 +1,22 @@
import { createServer } from 'vite';
import { readFileSync } from 'node:fs';
const server = await createServer({ server: { middlewareMode: true, hmr: false } });
try {
const { buildPier } = await server.ssrLoadModule('/src/core/geometry.ts');
const { generateDrawing, DEFAULT_SETTINGS } = await server.ssrLoadModule('/도면작성/drawing.ts');
const {presentationEntities} = await server.ssrLoadModule('/도면작성/presentation.ts');
const {entityBox,viewCollisions,occupiedBounds} = await server.ssrLoadModule('/도면작성/occupancy.ts');
const {bearingDetail} = await server.ssrLoadModule('/도면작성/bearing-detail.ts');
const sample = JSON.parse(readFileSync('랜딩페이지/assets/samples/'+(process.argv[2]||'sample01.pzr.json'), 'utf8'));
const input = sample.piers[0].input;
const doc = generateDrawing(buildPier(input), input, sample.project, DEFAULT_SETTINGS);
console.log(JSON.stringify(doc.quality.issues.map(q => ({q, a:doc.views.find(v=>v.id===q.view)?.entities[q.first], b:doc.views.find(v=>v.id===q.view)?.entities[q.second]})), null, 2));
console.log(JSON.stringify(doc.views.map(v=>({id:v.id,scale:v.scale,slot:v.slot}))));
for(const q of doc.quality.issues.filter(q=>q.kind==='text-text'))console.log(JSON.stringify(presentationEntities(doc.views.find(v=>v.id===q.view)).filter(p=>p.entity.kind==='text'&&[q.first,q.second].includes(p.index)).map(p=>({entity:p.entity,box:entityBox(p.entity)}))));
for(const scale of [50,75,100,125,150,200,250,300]){
const {entities}=bearingDetail(buildPier(input),scale);
for(const e of entities){if(e.kind==='dimension')e.height=3*scale;if(e.kind==='text')e.height=Math.max(e.height,2.5*scale);}
const v={id:'detail',entities,scale},b=occupiedBounds(v);
console.log(JSON.stringify({scale,width:(b.maxX-b.minX)/scale,issues:viewCollisions(v).map(q=>({a:entities[q.first],b:entities[q.second]}))}));
}
} finally { await server.close(); }
@@ -0,0 +1,52 @@
// Read the user's linked artifact in an isolated browser profile, without changing its content.
const fs = require('node:fs');
const path = require('node:path');
const out = path.resolve(__dirname, '../.verify/design-system-artifact-20261105');
fs.mkdirSync(out, { recursive: true });
async function inspect(target, name, screenshot) {
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let seq = 0;
const pending = new Map();
ws.onmessage = ({ data }) => {
const message = JSON.parse(data);
const wait = pending.get(message.id);
if (!wait) return;
clearTimeout(wait.timer); pending.delete(message.id);
if (message.error) wait.reject(new Error(JSON.stringify(message.error))); else wait.resolve(message.result);
};
const send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq;
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 15000);
pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ id, method, params }));
});
try {
await send('Runtime.enable');
if (screenshot) {
await send('Page.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await new Promise((resolve) => setTimeout(resolve, 6000));
}
const text = await send('Runtime.evaluate', { expression: 'document.body.innerText', returnByValue: true });
const html = await send('Runtime.evaluate', { expression: 'document.documentElement.outerHTML', returnByValue: true });
const frames = await send('Runtime.evaluate', { expression: '[...document.querySelectorAll("iframe")].map(f=>({src:f.src,title:f.title,srcdocLength:f.srcdoc.length}))', returnByValue: true });
fs.writeFileSync(path.join(out, `${name}.txt`), text.result?.value || '');
fs.writeFileSync(path.join(out, `${name}.html`), html.result?.value || '');
fs.writeFileSync(path.join(out, `${name}-frames.json`), JSON.stringify(frames.result?.value, null, 2));
console.log(JSON.stringify({ name, title: target.title, url: target.url, text: (text.result?.value || '').slice(0, 10000), frames: frames.result?.value }));
if (screenshot) {
const shot = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, 'overview.png'), Buffer.from(shot.data, 'base64'));
}
} finally { ws.close(); }
}
(async () => {
let targets = await (await fetch('http://127.0.0.1:9444/json')).json();
const page = targets.find((target) => target.type === 'page' && target.url.includes('9LPwY9MMugHfvF5auhpVW2'));
if (!page) throw new Error('Linked artifact page not found');
await inspect(page, 'page', true);
targets = await (await fetch('http://127.0.0.1:9444/json')).json();
let index = 0;
for (const target of targets.filter((target) => target.type === 'iframe')) await inspect(target, `frame-${++index}`, false);
})().catch((error) => { console.error(error); process.exitCode = 1; });
+21
View File
@@ -0,0 +1,21 @@
// Read focused-control and native-menu behaviour in the last verification tab.
const fs = require('node:fs');
const path = require('node:path');
(async () => {
const pages = await (await fetch('http://127.0.0.1:9444/json/list')).json();
const page = pages.find(p=>p.url==='http://127.0.0.1:5173/');
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise(r=>ws.onopen=r);
let seq=0; const pending=new Map();
ws.onmessage=({data})=>{const m=JSON.parse(data);if(pending.has(m.id)){pending.get(m.id)(m.result);pending.delete(m.id);}};
const send=(method,params={})=>new Promise(r=>{const id=++seq;pending.set(id,r);ws.send(JSON.stringify({id,method,params}));});
const ev=async expression=>(await send('Runtime.evaluate',{expression,returnByValue:true})).result.value;
const inspect=`({open:document.getElementById('file-menu').open,active:document.activeElement.outerHTML,summaryTab:document.querySelector('#file-menu summary').tabIndex})`;
const result={before:await ev(inspect)};
await ev(`document.querySelector('#file-menu summary').focus()`);
await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Enter',code:'Enter',text:'\r',windowsVirtualKeyCode:13});
await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Enter',code:'Enter',windowsVirtualKeyCode:13});
result.after=await ev(inspect);
fs.writeFileSync(path.resolve(__dirname,'../.verify/unified-input-20261106/keyboard-diagnosis.json'),JSON.stringify(result,null,2));
console.log(result);ws.close();
})().catch(e=>{console.error(e);process.exitCode=1;});
+49
View File
@@ -0,0 +1,49 @@
// Read-only design sample capture, fresh isolated CDP target. Run via Windows Node.
const fs=require('node:fs'), path=require('node:path');
const root=path.resolve(__dirname,'..'),out=path.join(root,'.verify/impeccable-20261105/a-after');
fs.mkdirSync(out,{recursive:true});
// Preserve the first animation-timed sample before the one allowed corrective batch.
if(fs.existsSync(path.join(out,'raw.json'))){const archive=path.join(out,'initial');fs.mkdirSync(archive,{recursive:true});for(const f of fs.readdirSync(out))if(/\.(png|json)$/.test(f))fs.copyFileSync(path.join(out,f),path.join(archive,f));}
const report={started:new Date().toISOString(),views:[],errors:[]};
const delay=ms=>new Promise(r=>setTimeout(r,ms));let ws;
(async()=>{
const target=await(await fetch('http://127.0.0.1:9444/json/new?'+encodeURIComponent('http://127.0.0.1:5173/'),{method:'PUT'})).json();report.target=target.id;
ws=new WebSocket(target.webSocketDebuggerUrl);await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j});
let id=0;const pending=new Map();ws.onmessage=({data})=>{const m=JSON.parse(data);if(m.id){const p=pending.get(m.id);if(p){pending.delete(m.id);clearTimeout(p.t);m.error?p.j(Error(JSON.stringify(m.error))):p.r(m.result)}}else if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params)};
const send=(method,params={})=>new Promise((r,j)=>{const n=++id,t=setTimeout(()=>j(Error('Timeout '+method)),20000);pending.set(n,{r,j,t});ws.send(JSON.stringify({id:n,method,params}))});
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw Error(JSON.stringify(r.exceptionDetails));return r.result.value};
const until=async(expression)=>{for(let n=0;n<100;n++){if(await ev(expression))return;await delay(200)}throw Error('Wait failed '+expression)};
const click=selector=>ev(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing ${selector}');e.click()})()`);
const cap=async name=>{await delay(600);fs.writeFileSync(path.join(out,name+'.png'),Buffer.from((await send('Page.captureScreenshot',{format:'png'})).data,'base64'));const facts=await ev(`({text:document.body.innerText,viewport:{width:innerWidth,height:innerHeight,scrollWidth:document.documentElement.scrollWidth,scrollHeight:document.documentElement.scrollHeight},controls:[...document.querySelectorAll('button,input,select,summary')].filter(e=>e.getBoundingClientRect().width&&e.getBoundingClientRect().height).map(e=>({tag:e.tagName,id:e.id,text:e.innerText||e.getAttribute('aria-label')||e.getAttribute('placeholder'),r:(()=>{const r=e.getBoundingClientRect();return{x:r.x,y:r.y,w:r.width,h:r.height}})(),font:getComputedStyle(e).fontSize}))})`);report.views.push({name,...facts});fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2))};
await send('Page.enable');await send('Runtime.enable');await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await until(`!!document.querySelector('[data-card="excel"] button')`);await delay(2500);
await click('[data-card="excel"] button');
await ev(`document.getElementById('project-name').value='디자인 검토 A';document.getElementById('project-name').dispatchEvent(new Event('input',{bubbles:true}))`);
const doc=await send('DOM.getDocument'),node=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});
await send('DOM.setFileInputFiles',{nodeId:node.nodeId,files:[path.resolve(root,'../../RAG/운곡교P1입력.xlsx')]});
await until(`!document.getElementById('dlg-apply').disabled`);await cap('01-import-1600');
report.importBefore=await ev(`({scope:document.querySelector('#dlg-selected').textContent,preview:document.querySelector('#pv-abut')?.innerText,button:document.querySelector('#dlg-apply').textContent})`);
await click('#import-abut-A1');report.importDeselected=await ev(`({scope:document.querySelector('#dlg-selected').textContent,button:document.querySelector('#dlg-apply').textContent,preview:document.querySelector('#pv-abut')?.innerText})`);await click('#import-abut-A1');
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});await cap('02-import-1280');
await click('#dlg-apply');await until(`!document.getElementById('import-dialog').open`);
await click('#nav-plan');await until(`[...document.querySelectorAll('#plan-pier-items button')].some(b=>b.textContent.includes('A1')&&b.textContent.includes('교대'))`);
await ev(`[...document.querySelectorAll('#plan-pier-items button')].find(b=>b.textContent.includes('A1')&&b.textContent.includes('교대')).click()`);await delay(4500);
await click('#nav-rebar');await until(`document.querySelectorAll('#rebar-tabs button').length===6`);
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
if(await ev(`document.querySelector('#review-model-toggle').getAttribute('aria-expanded')==='false'`))await click('#review-model-toggle');
await ev(`document.querySelector('#review-model-height').value='240';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);await cap('03-rebar-1600');
report.footTasks=await ev(`document.querySelector('#review-summary').textContent+' '+document.querySelector('#review-tasks').innerText`);
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});await cap('04-rebar-1280');
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('날개벽')).click()`);
report.wingTasks=await ev(`document.querySelector('#review-summary').textContent+' '+document.querySelector('#review-tasks').innerText`);
await click('#review-tasks button');report.taskJump=await ev(`({focused:document.activeElement?.innerText,selected:document.querySelector('#rebar-table').innerText.includes('강조 중')})`);
await ev(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.includes('확대기초')).click()`);
await ev(`document.querySelector('#review-model-height').value='180';document.querySelector('#review-model-height').dispatchEvent(new Event('input',{bubbles:true}))`);
await click('#review-inputs');report.inputJump=await ev(`({focused:document.activeElement?.id,scroll:document.querySelector('#review-workspace > .viewer').scrollTop})`);
await click('#review-model-toggle');await click('#review-table');
await ev(`document.querySelector('#view-rebar > .results').scrollTop=250`);await cap('05-collapsed-table-material-1280');
report.end=await ev(`({modelHidden:getComputedStyle(document.querySelector('#slot-rebar')).display,expanded:document.querySelector('#review-model-toggle').getAttribute('aria-expanded'),focused:document.activeElement.tagName,frames:[...document.querySelectorAll('#view-rebar > .panel,#review-workspace,#review-workspace > .viewer')].map(e=>({id:e.id,class:e.className,overflow:getComputedStyle(e).overflow,clientHeight:e.clientHeight,scrollHeight:e.scrollHeight,scrollTop:e.scrollTop}))})`);
report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.log(JSON.stringify(report));
await send('Page.close');ws.close();
})().catch(e=>{report.failure=String(e.stack||e);fs.writeFileSync(path.join(out,'raw.json'),JSON.stringify(report,null,2));console.error(e);if(ws)ws.close();process.exitCode=1});
+52
View File
@@ -0,0 +1,52 @@
// Independent design evidence: only the newly created Chrome tab is modified.
const fs = require('node:fs');
const path = require('node:path');
const [input, out] = process.argv.slice(2);
const report = { views: [], console: [], exceptions: [], started: new Date().toISOString() };
const sleep = ms => new Promise(r => setTimeout(r, ms));
let ws;
async function main() {
const target = await (await fetch('http://127.0.0.1:9444/json/new?http://127.0.0.1:5173/', { method: 'PUT' })).json();
report.targetId = target.id;
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r,j) => {ws.onopen=r; ws.onerror=j;});
let next=0; const pending=new Map();
ws.onmessage=({data})=> {const m=JSON.parse(data); if(m.id){const p=pending.get(m.id);if(p){clearTimeout(p.t);pending.delete(m.id);m.error?p.j(m.error):p.r(m.result);}} else if(m.method==='Runtime.consoleAPICalled')report.console.push({type:m.params.type,args:m.params.args.map(x=>x.value??x.description)});else if(m.method==='Runtime.exceptionThrown')report.exceptions.push(m.params.exceptionDetails);};
const send=(method,params={})=>new Promise((r,j)=>{const id=++next;const t=setTimeout(()=>j(new Error(method+' timeout')),20000);pending.set(id,{r,j,t});ws.send(JSON.stringify({id,method,params}));});
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,returnByValue:true,awaitPromise:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.text);return r.result.value;};
const until=async e=>{for(let n=0;n<100;n++){if(await ev(e))return;await sleep(150);}throw new Error('Missing state '+e);};
const click=async e=>ev(`(()=>{const n=${e};if(!n||n.disabled)throw new Error('Missing/disabled click');n.click();return true;})()`);
await send('Runtime.enable'); await send('Page.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await until(`typeof window.loadBytesToApp==='function'`);
report.injectionPreflight=await ev(`(()=>{document.title='[Human] Design evidence B';let s=document.createElement('script');s.textContent='window.__designBMutation=true';document.head.append(s);return window.__designBMutation===true;})()`);
const measure=async name=>{
if(process.env.OVERLAY_URL) report.overlayInjection=await ev(`new Promise(resolve=>{document.title='[Human] Design evidence B';scrollTo(0,0);const s=document.createElement('script');s.src=${JSON.stringify(process.env.OVERLAY_URL)};s.onload=()=>setTimeout(()=>resolve({loaded:true}),2600);s.onerror=()=>resolve({loaded:false});document.head.append(s);})`);
await sleep(350);
const facts=await ev(`(()=>{const visible=e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0&&getComputedStyle(e).visibility!=='hidden'};const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom}};const controls=[...document.querySelectorAll('button,input,select,textarea')].filter(visible);const fields=controls.filter(e=>/INPUT|SELECT|TEXTAREA/.test(e.tagName));const named=e=>!!(e.labels?.length||e.getAttribute('aria-label')||e.getAttribute('aria-labelledby')||e.title);return {viewport:{w:innerWidth,h:innerHeight},doc:{w:document.documentElement.scrollWidth,h:document.documentElement.scrollHeight},text:document.body.innerText,controls:controls.map(e=>({tag:e.tagName,id:e.id,text:(e.innerText||e.getAttribute('aria-label')||e.value||'').slice(0,90),...rect(e),font:getComputedStyle(e).fontSize,disabled:e.disabled})),unnamedFields:fields.filter(e=>!named(e)).map(e=>({id:e.id,type:e.type,...rect(e)})),overflow:[...document.querySelectorAll('body *')].filter(visible).filter(e=>e.scrollWidth>e.clientWidth+2&&e.clientWidth>0).map(e=>({tag:e.tagName,id:e.id,cls:e.className,client:e.clientWidth,scroll:e.scrollWidth,overflowX:getComputedStyle(e).overflowX,...rect(e)})).slice(0,40),active:{id:document.activeElement.id,tag:document.activeElement.tagName},dialogs:[...document.querySelectorAll('dialog[open]')].map(rect)};})()`);
facts.name=name;report.views.push(facts);
const shot=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(shot.data,'base64'));
};
await measure('01-landing-desktop');
await click(`document.querySelector('[data-card="excel"] button')`);
await ev(`document.getElementById('project-name').value='디자인 독립 검증 B'`);
const doc=await send('DOM.getDocument');const file=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#file'});
await send('DOM.setFileInputFiles',{nodeId:file.nodeId,files:[input]});
await until(`!document.getElementById('dlg-apply').disabled`);
await measure('02-import-desktop');
await click(`document.getElementById('dlg-apply')`);
await click(`document.getElementById('nav-plan')`);
await click(`[...document.querySelectorAll('#plan-pier-items button')].find(b=>b.textContent.includes('교대')&&b.textContent.includes('A1'))`);
await measure('03-A1-plan-desktop');
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.includes('교대')&&b.textContent.includes('A1'))`);
await until(`document.querySelectorAll('#rebar-tabs button').length===6`);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('벽체'))`);
await measure('04-A1-rebar-desktop');
await send('Emulation.setDeviceMetricsOverride',{width:1280,height:800,deviceScaleFactor:1,mobile:false});
await measure('05-A1-rebar-laptop');
await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Tab',code:'Tab',windowsVirtualKeyCode:9});await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Tab',code:'Tab',windowsVirtualKeyCode:9});
report.keyboardFocus=await ev(`(()=>{const e=document.activeElement;const s=getComputedStyle(e);return {tag:e.tagName,id:e.id,text:e.innerText?.slice(0,80),outline:s.outline,focusVisible:e.matches(':focus-visible')}})()`);
report.result='COMPLETE';
}
fs.mkdirSync(out,{recursive:true});main().catch(e=>{report.result='PARTIAL';report.failure=e.stack;process.exitCode=1;}).finally(()=>{report.finished=new Date().toISOString();fs.writeFileSync(path.join(out,'browser.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({result:report.result,views:report.views.length,failure:report.failure}));ws?.close();});
+166
View File
@@ -0,0 +1,166 @@
// Build non-destructive fixtures and verify full-name identity and error disclosure via CDP.
// Run with Windows Node while the local app and a dedicated Chrome CDP port 9444 are running.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const XLSX = require('xlsx');
const root = path.resolve(__dirname, '..');
const out = path.join(root, '.verify', 'abut-names-20261105');
const report = { checks: [], errors: [], started: new Date().toISOString() };
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
fs.mkdirSync(out, { recursive: true });
const base = XLSX.readFile(path.resolve(root, '../../RAG/운곡교P1입력.xlsx'));
const blocks = { info: [], ips: [], bearings: [] };
for (const sheet of base.SheetNames) {
const rows = XLSX.utils.sheet_to_json(base.Sheets[sheet], { header: 1, defval: null });
for (let h = 0; h < rows.length; h++) {
if (rows[h][0] !== '교대명') continue;
const kind = ({ 형식: 'info', IP점: 'ips', 받침번호: 'bearings' })[rows[h][1]];
if (!kind) continue;
let name;
for (let i = h + 1; i < rows.length && rows[i][1] != null && rows[i][1] !== ''; i++) {
name = rows[i][0] || name;
if (name === 'A1') blocks[kind].push(['A2-완도', ...rows[i].slice(1)]);
}
}
for (const row of rows) if (row[0] === 'A1') row[0] = 'A1-완도';
base.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows);
}
assert.equal(blocks.info.length, 1);
blocks.info[0][1] = 'A2';
XLSX.utils.book_append_sheet(base, XLSX.utils.aoa_to_sheet([
['교대명', '형식'], ...blocks.info, [],
['교대명', 'IP점'], ...blocks.ips, [],
['교대명', '받침번호'], ...blocks.bearings,
]), '종점교대');
const validFile = path.join(out, 'full-names.xlsx');
XLSX.writeFile(base, validFile);
const bad = XLSX.readFile(validFile);
for (const sheet of bad.SheetNames) {
const rows = XLSX.utils.sheet_to_json(bad.Sheets[sheet], { header: 1, defval: null });
for (const row of rows) if (row[0] === 'A1-완도' && row[1] === 'A1') row[1] = 'A3';
bad.Sheets[sheet] = XLSX.utils.aoa_to_sheet(rows);
}
const badFile = path.join(out, 'invalid-type.xlsx');
XLSX.writeFile(bad, badFile);
let ws;
let send;
async function main() {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let seq = 0;
let loaded;
let downloadGuid;
const done = new Set();
const pending = new Map();
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq;
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout ${method}`)); }, 20000);
pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ id, method, params }));
});
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.id) {
const wait = pending.get(msg.id); if (!wait) return;
clearTimeout(wait.timer); pending.delete(msg.id);
if (msg.error) wait.reject(new Error(JSON.stringify(msg.error))); else wait.resolve(msg.result);
} else if (msg.method === 'Page.loadEventFired') loaded?.();
else if (msg.method === 'Runtime.exceptionThrown') report.errors.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text);
else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') report.errors.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
else if (msg.method === 'Browser.downloadWillBegin') downloadGuid = msg.params.guid;
else if (msg.method === 'Browser.downloadProgress' && msg.params.state === 'completed') done.add(msg.params.guid);
};
const ev = async (expression) => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async (expression) => {
for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error(`Timeout: ${expression}`);
};
const click = (expression) => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target'); e.click(); })()`);
const check = (name, condition) => { assert.ok(condition, name); report.checks.push(name); console.log('PASS', name); };
const shot = async (name) => {
await delay(250);
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(r.data, 'base64'));
};
const file = async (selector, name) => {
const doc = await send('DOM.getDocument');
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector });
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [name] });
};
const navigate = async () => {
let timer;
const load = new Promise((resolve, reject) => { loaded = resolve; timer = setTimeout(() => reject(new Error('Load timeout')), 20000); });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
try { await load; } finally { clearTimeout(timer); loaded = undefined; }
await until(`typeof window.loadBytesToApp === 'function' && !!document.querySelector('[data-card="excel"] button')`);
};
const select = (name) => click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes(${JSON.stringify(name)}) && b.textContent.includes('교대'))`);
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
const downloads = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-abut-name-20261105\\downloads';
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
await navigate();
await click(`document.querySelector('[data-card="excel"] button')`);
await ev(`document.getElementById('project-name').value = '복합 교대명 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`);
await file('#file', validFile);
await until(`document.getElementById('dlg-apply').textContent.includes('교대 2기')`);
check('both full names recognized without cascade errors', await ev(`document.getElementById('pv-abuts').textContent.includes('A1-완도') && document.getElementById('pv-abuts').textContent.includes('A2-완도') && !document.querySelector('#sheets .bad')`));
check('full-name checkboxes stay distinct', await ev(`document.getElementById('import-abut-A1-완도').checked && document.getElementById('import-abut-A2-완도').checked`));
await shot('01-full-names-import');
await click(`document.getElementById('dlg-apply')`);
await click(`document.getElementById('nav-rebar')`);
await select('A1-완도');
check('full name selects the start abutment', await ev(`document.getElementById('rebar-note').textContent.startsWith('A1-완도') && document.querySelectorAll('#rebar-tabs button').length === 6`));
await select('A2-완도');
check('full name selects the end abutment independently', await ev(`document.getElementById('rebar-note').textContent.startsWith('A2-완도')`));
const order = await ev(`[...document.querySelectorAll('#rebar-pier-items button')].map(b => b.textContent)`);
check('type-based order is preserved', order[0].includes('A1-완도') && order[1].includes('P1') && order[2].includes('A2-완도'));
await shot('02-independent-abutments');
await click(`document.getElementById('save-project')`);
await until(`document.getElementById('save-dialog').open`);
await click(`document.getElementById('sv-save')`);
for (let i = 0; i < 100 && !(downloadGuid && done.has(downloadGuid)); i++) await delay(150);
assert.ok(downloadGuid && done.has(downloadGuid), 'actual download completes');
const text = fs.readFileSync(path.join(downloads, downloadGuid), 'utf8');
const project = JSON.parse(text);
const expectedNames = ['A1-완도', 'A2-완도'];
assert.deepEqual(project.abutments.map((a) => a.name), expectedNames);
assert.deepEqual(project.abutDefaults.map((a) => a.name), expectedNames);
report.checks.push('actual download preserves both names and defaults');
const saved = path.join(out, 'full-names.pzr.json'); fs.writeFileSync(saved, text);
await navigate();
await ev('window.openSampleProject()');
await click(`document.getElementById('open-project')`);
await file('#project-file', saved);
await until(`document.querySelector('#toast-host').textContent.includes('열었습니다')`);
await click(`document.getElementById('nav-rebar')`);
await select('A1-완도'); await select('A2-완도');
check('fresh reopen regenerates full-name abutments', await ev(`document.getElementById('save-status').textContent === '저장됨' && document.getElementById('rebar-note').textContent.startsWith('A2-완도')`));
await shot('03-reopened');
await click(`document.getElementById('open-import')`);
await file('#file', badFile);
await until(`document.getElementById('dlg-apply').textContent.includes('교대 1기') && !!document.querySelector('#sheets details')`);
check('one bad definition does not disable another name', await ev(`document.getElementById('import-abut-A2-완도').checked && !document.getElementById('import-abut-A2-완도').disabled`));
check('dependent rows are collapsed under the root', await ev(`document.querySelectorAll('#sheets details').length === 1 && !document.querySelector('#sheets details').open && document.querySelector('#sheets details summary').textContent.includes('8개 행') && document.querySelectorAll('#sheets details p').length === 8`));
await shot('04-root-error-collapsed');
await click(`document.querySelector('#sheets details summary')`);
check('dependent rows remain available for diagnosis', await ev(`document.querySelector('#sheets details').open && document.querySelector('#sheets details').textContent.includes('IP') && document.querySelector('#sheets details').textContent.includes('받침')`));
await shot('05-related-rows');
check('no browser runtime errors', report.errors.length === 0);
report.result = 'PASS';
}
main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => {
if (report.result === 'FAIL' && send) {
try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* Keep the original error. */ }
}
report.finished = new Date().toISOString(); fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure }));
if (send) { try { await send('Page.close'); } catch { /* The tab may already be closed. */ } }
ws?.close();
});
+76
View File
@@ -0,0 +1,76 @@
// 교대만 가져오기 (2026-11-06): CDP 9444 Chrome + Vite 5173. Imports the sample workbook with P1 unchecked and A1 only,
// then walks ① ② ③, and checks for runtime exceptions; screenshots go to .verify/abut-only-20261106.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/abut-only-20261106');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [], console: [] };
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
const ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') report.console.push(m.params.args.map(a => a.value ?? a.description).join(' '));
};
const send = (method, params = {}) => new Promise((resolve, reject) => { const id = ++seq; pending.set(id, { resolve, reject }); ws.send(JSON.stringify({ id, method, params })); });
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expr => { for (let i = 0; i < 100; i++) { if (await ev(expr)) return; await delay(150); } throw new Error(`Wait failed: ${expr}`); };
const click = expr => ev(`(() => { const e = ${expr}; if (!e || e.disabled) throw new Error('Missing/disabled: ' + ${JSON.stringify(expr)}); e.click(); })()`);
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
const shot = async name => { await delay(400); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
try {
await send('Page.enable'); await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.loadBytesToApp === 'function'`);
await ev(`(async () => { const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); window.loadBytesToApp(new Uint8Array(b), '운곡교P1입력.xlsx'); })()`);
await until(`!!document.getElementById('import-abut-A1')`);
// Uncheck every pier, keep A1.
await ev(`(() => { for (const c of document.querySelectorAll('input[data-import-kind="pier"]')) if (c.checked) { c.checked = false; c.dispatchEvent(new Event('change', { bubbles: true })); } })()`);
await ev(`(() => { const c = document.getElementById('import-abut-A1'); if (!c.checked) { c.checked = true; c.dispatchEvent(new Event('change', { bubbles: true })); } })()`);
await ev(`(() => { const e = document.getElementById('project-name'); e.value = '교대만 검증'; e.dispatchEvent(new Event('input', { bubbles: true })); })()`);
const label = await ev(`document.getElementById('dlg-apply').textContent`);
check(`apply button allows A1 only: "${label}"`, !(await ev(`document.getElementById('dlg-apply').disabled`)) && label.includes('교각 0기 + 교대 1기'));
check('dialog note says abutments only', (await ev(`document.getElementById('dlg-selected').textContent`)).includes('교대만 가져옵니다'));
await click(`document.getElementById('dlg-apply')`);
await until(`!document.querySelector('.app').hidden`);
await shot('1-shape');
check('① list shows A1', (await ev(`document.body.innerText`)).includes('A1 · A1 교대'));
// WebGL canvases cannot be read back without preserveDrawingBuffer: the 3D view is judged on the screenshots.
for (const [nav, name] of [['nav-plan', '2-plan'], ['nav-rebar', '3-rebar'], ['nav-shape', '4-shape-again']]) {
await click(`document.getElementById('${nav}')`);
await shot(name);
// Pick the abutment in the visible list of this stage, when there is one.
await ev(`(() => { const b = [...document.querySelectorAll('main:not([hidden]) button')].find(x => x.textContent.startsWith('A1 ·')); if (b) b.click(); })()`);
await shot(name + '-A1');
}
check('no runtime exceptions (교대만)', report.errors.length === 0);
// Regression: P1 + A1 together still import, and P1 front-row solplates pair with A1.
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.loadBytesToApp === 'function'`);
await ev(`(async () => { const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer(); window.loadBytesToApp(new Uint8Array(b), '운곡교P1입력.xlsx'); })()`);
await until(`!!document.getElementById('import-abut-A1')`);
await ev(`(() => { const e = document.getElementById('project-name'); e.value = '교각+교대 검증'; e.dispatchEvent(new Event('input', { bubbles: true })); })()`);
check('P1 + A1 apply label', (await ev(`document.getElementById('dlg-apply').textContent`)).includes('교각 1기 + 교대 1기'));
await click(`document.getElementById('dlg-apply')`);
await until(`!document.querySelector('.app').hidden`);
await click(`document.getElementById('nav-plan')`);
await shot('5-pier-plan');
check('no runtime exceptions (교각 + 교대)', report.errors.length === 0);
} catch (err) {
report.failure = String(err && err.stack || err);
console.error(report.failure);
} finally {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ checks: report.checks.length, errors: report.errors, console: report.console.slice(0, 5), failure: report.failure }, null, 2));
ws.close();
}
})();
+90
View File
@@ -0,0 +1,90 @@
// ③ 교대 벽체 카드 화면 확인 (2026-11-04): 헤드리스 Chrome(CDP)으로 입력 파일을 불러와 A1 → ③ → 벽체를 열고 스크린샷을 남긴다.
// 사용 (WSL Node ≥ 22, 개발 서버 실행 중):
// chrome-headless-shell --headless --remote-debugging-port=9444 & (~/.cache/ms-playwright/chromium_headless_shell-*/)
// node scripts/verify-abut-wall.cjs <input.xlsx> <out dir>
const fs = require('fs');
const path = require('path');
const APP_URL = process.env.APP_URL || 'http://127.0.0.1:5173/';
const CDP = process.env.CDP || 'http://127.0.0.1:9444';
const [INPUT_FILE, OUT_DIR] = process.argv.slice(2);
if (!INPUT_FILE || !OUT_DIR) throw new Error('INPUT_FILE and OUT_DIR are required');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
const targets = await (await fetch(`${CDP}/json`)).json();
const page = targets.find((t) => t.type === 'page');
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((res, rej) => ((ws.onopen = res), (ws.onerror = rej)));
let seq = 0;
const waiting = new Map();
const consoleEvents = [];
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.id && waiting.has(m.id)) {
waiting.get(m.id)(m);
waiting.delete(m.id);
} else if (m.method === 'Runtime.exceptionThrown') consoleEvents.push(`exception: ${m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text}`);
else if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') consoleEvents.push(`console.error: ${m.params.args.map((a) => a.value ?? a.description ?? '').join(' ')}`);
};
const send = (method, params = {}) => new Promise((res) => { const id = ++seq; waiting.set(id, res); ws.send(JSON.stringify({ id, method, params })); });
const evalJs = async (expression) => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.result.exceptionDetails) throw new Error(JSON.stringify(r.result.exceptionDetails).slice(0, 400));
return r.result.result.value;
};
const shot = async (name, full = false) => {
const params = { format: 'png' };
if (full) {
const m = await send('Page.getLayoutMetrics');
params.clip = { x: 0, y: 0, width: 1600, height: Math.min(m.result.cssContentSize.height, 6000), scale: 1 };
params.captureBeyondViewport = true;
}
const r = await send('Page.captureScreenshot', params);
fs.writeFileSync(path.join(OUT_DIR, name), Buffer.from(r.result.data, 'base64'));
console.log(`SHOT ${name}`);
};
const click = (js) => evalJs(`(() => { const e = ${js}; if (!e) throw new Error('없음: ${js.replace(/'/g, '')}'); e.click(); return true; })()`);
await send('Page.enable');
await send('Runtime.enable');
await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: APP_URL });
await sleep(4000);
await click(`document.getElementById('open-import')`);
await sleep(500);
const doc = await send('DOM.getDocument', { depth: -1 });
const q = await send('DOM.querySelector', { nodeId: doc.result.root.nodeId, selector: '#file' });
await send('DOM.setFileInputFiles', { nodeId: q.result.nodeId, files: [INPUT_FILE] });
await sleep(3000);
await click(`document.getElementById('dlg-apply')`);
await sleep(2500);
await click(`document.getElementById('nav-rebar')`);
await sleep(1500);
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find((b) => b.textContent.includes('교대'))`);
await sleep(2000);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find((b) => b.textContent.startsWith('벽체'))`);
await sleep(2000);
console.log('STATUS', await evalJs(`document.getElementById('step3-status').textContent`));
console.log('TABS', await evalJs(`[...document.querySelectorAll('#rebar-tabs button')].map((b) => b.textContent).join(' | ')`));
console.log('NOTE', await evalJs(`document.getElementById('rebar-note').textContent`));
console.log('ROWS', await evalJs(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter((r) => r.cells.length > 2).map((r) => [r.cells[2].textContent, r.cells[7].textContent, r.cells[8].textContent].join(' ')).join(' | ')`));
console.log('ISSUES', await evalJs(`document.getElementById('rebar-issues').innerText.replace(/\\n+/g, ' / ')`));
console.log('BARS3D', await evalJs(`document.querySelectorAll('canvas').length`));
await shot('wall-1-section.png', true);
await click(`[...document.querySelectorAll('.abut-optt')].find((b) => b.textContent.startsWith('교대축 단면'))`);
await sleep(1200);
await shot('wall-2-elevation.png', true);
// W9 행 선택 → 선택만 보기.
await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find((r) => r.cells[2] && r.cells[2].textContent === 'W9')`);
await sleep(800);
await click(`[...document.querySelectorAll('#rebar-table .rebar-select button')].find((b) => b.textContent === '선택만 보기')`);
await sleep(1500);
console.log('SELECTED', await evalJs(`document.querySelector('#rebar-table .rebar-select span').textContent`));
await shot('wall-3-w9-only.png');
console.log(`CONSOLE_EVENTS ${consoleEvents.length}`);
for (const e of consoleEvents) console.log(` ${e}`);
ws.close();
}
main().catch((e) => { console.error('FAILED', e); process.exit(1); });
+241
View File
@@ -0,0 +1,241 @@
// Verify A1's six rebar cards, rendered views, edits and project round trip via CDP.
// Run with Windows Node when CDP is served by Windows Chrome:
// node scripts/verify-abut-workflow.cjs <input.xlsx> <output-directory> <download-directory>
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const [input, out, downloads, mode] = process.argv.slice(2);
if (!input || !out || !downloads) throw new Error('input, output and download directories are required');
const base = process.env.CDP || 'http://127.0.0.1:9444';
const url = process.env.APP_URL || 'http://127.0.0.1:5173/';
const report = mode === '--resume'
? JSON.parse(fs.readFileSync(path.join(out, 'report.json'), 'utf8'))
: { input, url, started: new Date().toISOString(), cards: [], errors: [], checks: [] };
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
fs.mkdirSync(out, { recursive: true });
let ws;
async function main() {
console.log('Connecting to Chrome', base);
const targets = await (await fetch(`${base}/json`, { signal: AbortSignal.timeout(10000) })).json();
const target = targets.find((t) => t.type === 'page');
assert.ok(target, 'Chrome page target');
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let next = 0;
const pending = new Map();
const completed = new Set();
let downloadGuid;
let loaded;
ws.onmessage = ({ data }) => {
const message = JSON.parse(data);
if (message.id) {
const waiter = pending.get(message.id);
if (!waiter) return;
clearTimeout(waiter.timer);
pending.delete(message.id);
if (message.error) waiter.reject(new Error(JSON.stringify(message.error)));
else waiter.resolve(message.result);
} else if (message.method === 'Page.loadEventFired') loaded?.();
else if (message.method === 'Runtime.exceptionThrown') {
report.errors.push(message.params.exceptionDetails.exception?.description || message.params.exceptionDetails.text);
} else if (message.method === 'Runtime.consoleAPICalled' && message.params.type === 'error') {
report.errors.push(message.params.args.map((v) => v.value ?? v.description ?? '').join(' '));
} else if (message.method === 'Browser.downloadWillBegin') downloadGuid = message.params.guid;
else if (message.method === 'Browser.downloadProgress' && message.params.state === 'completed') completed.add(message.params.guid);
};
const send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++next;
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout: ${method}`)); }, 20000);
pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ id, method, params }));
});
const evaluate = async (expression) => {
const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text);
return result.result.value;
};
const navigate = async () => {
let timer;
const event = new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error('Page load timeout')), 20000);
loaded = resolve;
});
await send('Page.navigate', { url });
try { await event; } finally { clearTimeout(timer); loaded = undefined; }
await until(() => evaluate(`typeof window.loadBytesToApp === 'function'`), 'app event handlers loaded');
};
const until = async (test, label) => {
const end = Date.now() + 20000;
while (Date.now() < end) {
if (await test()) return;
await delay(150);
}
report.timeoutPage = await evaluate('document.body.innerText');
const shot = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64'));
throw new Error(`Timeout: ${label}`);
};
const click = (expression) => evaluate(`(() => { const node = ${expression}; if (!node) throw new Error('Missing click target'); if (node.disabled) throw new Error('Disabled click target'); node.click(); return true; })()`);
const selectCard = async (label) => {
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`);
await until(() => evaluate(`document.querySelector('#rebar-table svg') !== null`), label);
await delay(250);
};
const screenshot = async (name) => {
const shot = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(shot.data, 'base64'));
};
const upload = async (selector, file) => {
const doc = await send('DOM.getDocument');
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector });
assert.ok(node.nodeId, selector);
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [file] });
};
const editFirst = async (value) => evaluate(`(() => {
const node = document.querySelector('#rebar-table .abut-rebar-inputs input[type=number]');
if (!node) throw new Error('Missing rebar input');
const old = { id: node.id, value: node.value };
node.value = ${JSON.stringify(value)};
node.dispatchEvent(new Event('change', { bubbles: true }));
return old;
})()`);
const rows = () => evaluate(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r => r.cells.length > 2).map(r => r.innerText)`);
const restoreView = async (card) => {
for (const text of card.context) {
await click(`[...document.querySelectorAll('#rebar-table .abut-optt')].find(b => b.textContent === ${JSON.stringify(text)})`);
}
};
await send('Page.enable');
await send('Runtime.enable');
await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
await send('Page.addScriptToEvaluateOnNewDocument', { source: `
window.__drawCalls = 0;
for (const type of [window.WebGLRenderingContext, window.WebGL2RenderingContext]) {
if (!type) continue;
for (const method of ['drawElements', 'drawArrays', 'drawElementsInstanced', 'drawArraysInstanced']) {
const original = type.prototype[method];
if (original) type.prototype[method] = function(...args) { window.__drawCalls++; return original.apply(this, args); };
}
}
` });
if (mode !== '--resume') {
await navigate();
await until(() => evaluate(`document.querySelector('[data-card="excel"] button') !== null && typeof window.loadBytesToApp === 'function'`), 'app loaded');
await click(`document.querySelector('[data-card="excel"] button')`);
await evaluate(`const name = document.getElementById('project-name'); name.value = 'A1 화면 검증'; name.dispatchEvent(new Event('input', { bubbles: true }));`);
await upload('#file', input);
await until(() => evaluate(`!document.getElementById('dlg-apply').disabled`), 'Excel import');
report.import = await evaluate(`({ sheets: document.getElementById('sheets').innerText, selection: document.getElementById('dlg-selected').textContent })`);
await screenshot('01-import');
await click(`document.getElementById('dlg-apply')`);
await click(`document.getElementById('nav-rebar')`);
await until(() => evaluate(`[...document.querySelectorAll('#rebar-pier-items button')].some(b => b.textContent.includes('교대'))`), 'abutment list');
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('교대') && b.textContent.includes('A1'))`);
await until(() => evaluate(`document.querySelectorAll('#rebar-tabs button').length === 6`), 'six member cards');
for (const [index, label] of ['받침블럭', '벽체', '흉벽', '확대기초', '날개벽', '접속슬래브'].entries()) {
await selectCard(label);
const before = await rows();
assert.ok(before.length, `${label}: non-empty rebar table`);
const card = { label, rows: before, views: [] };
const views = await evaluate(`[...document.querySelectorAll('#rebar-table .abut-optt')].map(b => b.textContent)`);
assert.ok(views.length >= 2, `${label}: view tabs`);
for (const [viewIndex, view] of views.entries()) {
const drawBefore = await evaluate('window.__drawCalls');
await click(`[...document.querySelectorAll('#rebar-table .abut-optt')].find(b => b.textContent === ${JSON.stringify(view)})`);
await delay(350);
const facts = await evaluate(`({ svg: !!document.querySelector('#rebar-table svg'), badSvg: /(?:NaN|Infinity)/.test(document.querySelector('#rebar-table svg')?.outerHTML || ''), drawCalls: window.__drawCalls, canvases: [...document.querySelectorAll('canvas')].map(c => ({ width: c.width, height: c.height })) })`);
assert.ok(facts.svg && !facts.badSvg, `${label}/${view}: finite SVG`);
assert.ok(facts.drawCalls > drawBefore, `${label}/${view}: WebGL draw calls`);
await screenshot(`${String(index + 2).padStart(2, '0')}-${index}-${viewIndex}`);
fs.writeFileSync(path.join(out, `${index}-${viewIndex}.svg`), await evaluate(`document.querySelector('#rebar-table svg').outerHTML`));
card.views.push({ view, ...facts });
}
const originalValue = await evaluate(`document.querySelector('#rebar-table .abut-rebar-inputs input[type=number]').value`);
const changedValue = originalValue === '150' ? '200' : '150';
const old = await editFirst(changedValue);
const changed = await rows();
assert.notDeepEqual(changed, before, `${label}: input recomputes rows`);
assert.equal(await evaluate(`document.getElementById(${JSON.stringify(old.id)}).value`), changedValue);
card.edit = { ...old, changedValue, changedRows: changed };
{
const marks = await evaluate(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r => r.cells.length > 2).map(r => r.cells[2].textContent)`);
assert.ok(marks[0], `${label}: selectable mark`);
await click(`document.querySelector('#rebar-table table.rebar-table tbody tr')`);
await delay(150);
if (!await evaluate(`document.querySelector('#rebar-table .rebar-select button:last-child').getAttribute('aria-pressed') === 'true'`)) {
await click(`[...document.querySelectorAll('#rebar-table .rebar-select button')].find(b => b.textContent === '선택만 보기')`);
}
await delay(250);
card.selection = await evaluate(`document.querySelector('#rebar-table .rebar-select').innerText`);
assert.ok(card.selection.includes('선택 1 /'), `${label}: one selected bar`);
await screenshot(`${String(index + 2).padStart(2, '0')}-${index}-selected`);
}
card.context = await evaluate(`[...document.querySelectorAll('#rebar-table .abut-optt[aria-pressed="true"]')].map(b => b.textContent)`);
report.cards.push(card);
console.log('Verified card', label);
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
}
// Snapshot all tables after every edit, including dependent members and the wing side.
for (const card of report.cards) {
await selectCard(card.label);
await restoreView(card);
card.edit.changedRows = await rows();
}
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
await click(`document.getElementById('save-project')`);
await until(() => evaluate(`document.getElementById('save-dialog').open`), 'save dialog');
await evaluate(`document.getElementById('sv-name').value = 'A1 화면 검증'; document.getElementById('sv-bridge').value = 'A1-verification'`);
await click(`document.getElementById('sv-save')`);
await until(async () => downloadGuid && completed.has(downloadGuid), 'download completes');
const downloaded = fs.readFileSync(path.join(downloads, downloadGuid), 'utf8');
const project = JSON.parse(downloaded);
report.savedAbutments = project.abutments;
assert.ok(project.abutments?.length, 'project contains abutments');
const savedFile = path.join(out, 'A1-roundtrip.pzr.json');
fs.writeFileSync(savedFile, downloaded);
report.checks.push('actual project download');
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
}
await navigate();
// Enter the app shell through its existing sample entry, then open the saved file.
// This uses no original Excel file for reconstruction.
await evaluate('window.openSampleProject()');
await click(`document.getElementById('open-project')`);
await upload('#project-file', path.join(out, 'A1-roundtrip.pzr.json'));
await until(() => evaluate(`document.querySelector('#toast-host')?.textContent.includes('열었습니다') && document.getElementById('save-status').textContent === '저장됨'`), 'open saved project');
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('교대') && b.textContent.includes('A1'))`);
for (const card of report.cards) {
await selectCard(card.label);
await restoreView(card);
const restored = await evaluate(`document.getElementById(${JSON.stringify(card.edit.id)})?.value`);
assert.equal(restored, card.edit.changedValue, `${card.label}: restored input`);
assert.deepEqual(await rows(), card.edit.changedRows, `${card.label}: restored computed rows`);
card.roundtrip = 'PASS';
}
await screenshot('09-restored');
report.checks.push('6 member cards', 'all SVG view tabs', 'WebGL drawing', '6 input edits', '6 bar selections', 'actual project download', 'fresh page project reopen', '6 restored inputs and computed tables');
assert.equal(report.errors.length, 0, 'browser runtime errors');
delete report.failure;
delete report.timeoutPage;
report.checks = [...new Set(report.checks)];
report.result = 'PASS';
}
main().catch((error) => {
report.result = 'FAIL';
report.failure = error.stack;
process.exitCode = 1;
}).finally(() => {
report.finished = new Date().toISOString();
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ result: report.result, cards: report.cards.length, errors: report.errors, failure: report.failure }));
ws?.close();
});
+140
View File
@@ -0,0 +1,140 @@
// Actual app verification in an isolated Windows Chrome, CDP port 9444.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/cast-detail-20261105');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
const delay = ms => new Promise(r => setTimeout(r, ms));
let ws, send;
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0, download;
const completed = new Set(), pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
if (m.method === 'Browser.downloadWillBegin') download = m.params.guid;
if (m.method === 'Browser.downloadProgress' && m.params.state === 'completed') completed.add(m.params.guid);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 25000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => { for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); } throw new Error(`Wait failed: ${expression}`); };
const click = expression => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target: ' + ${JSON.stringify(expression)}); e.click(); })()`);
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
const file = async name => {
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [name] });
};
const shot = async name => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
const member = text => click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(text)}))`);
const select = (id, name) => click(`[...document.querySelectorAll('#${id} button')].find(b => b.textContent.startsWith(${JSON.stringify(name + ' ·')}))`);
const set = (id, value) => ev(`document.getElementById(${JSON.stringify(id)}).value = ${JSON.stringify(String(value))}`);
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
const downloads = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-cast-detail-20261105\\downloads';
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject === 'function'`);
const fixture = await ev(`(async () => {
const XLSX = await import('/node_modules/xlsx/xlsx.mjs');
const {parseWorkbook} = await import('/src/core/parseWorkbook.ts');
const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base = parseWorkbook(XLSX.read(b)).piers.find(p=>p.name==='P1');
const piers=[];
for (const [type,columnCount] of [['T형',1],['2주형',2],['3주형',3]]) for (const foundationType of ['현장타설말뚝','직접기초','말뚝기초']) {
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType};
piers.push({id:input.name,input,defaults:input,draft:input});
}
return {schemaVersion:1,project:{name:'현장타설 입력 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.getElementById('open-project')`); await file(fixturePath);
await until(`document.querySelectorAll('#plan-pier-items button').length === 9`);
for (let i = 1; i <= 9; i++) {
const cast = [1,4,7].includes(i);
await click(`document.getElementById('nav-plan')`); await select('plan-pier-items', 'P'+i);
await click(`[...document.querySelectorAll('#members button')].find(b => b.textContent.startsWith(${JSON.stringify(cast ? '현장타설말뚝' : '확대기초')}))`);
check(`P${i} plan: appropriate foundation controls`, await ev(cast
? `!!document.querySelector('#members button[disabled]') && !!document.querySelector('#member-fields #edit-pileLengthMm') && !document.querySelector('#member-fields #edit-footingHeightMm')`
: `!!document.querySelector('#member-fields #edit-footingHeightMm:not([disabled])')`));
await click(`document.getElementById('nav-rebar')`); await member('코핑');
check(`P${i} entering rebar builds coping drawings immediately`, await ev(`!!document.querySelector('#rebar-table figure svg')`));
await member('기둥');
check(`P${i} rebar: dedicated or legacy column`, await ev(cast
? `!!document.querySelector('[data-cast-part="column"]') && [...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('확대기초')).disabled`
: `!!document.getElementById('column-nDiv') && !document.querySelector('[data-cast-part]')`));
}
await click(`document.getElementById('nav-plan')`); await select('plan-pier-items','P1');
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.startsWith('현장타설말뚝'))`);
const ground = fixture.piers[0].input.groundEl, tip = ground-fixture.piers[0].input.pileLengthMm/1000;
check('clarified rock EL offsets and fixed beta default appear in the shape card', await ev(`Number(document.getElementById('cip-shape-betaLengthMm').value)===9000 && Number(document.getElementById('cip-shape-weatheredRockEl').value)===${tip+5} && Number(document.getElementById('cip-shape-bedrockEl').value)===${tip+1}`));
check('independent clay EL is initially blank and casing fields inactive', await ev(`document.getElementById('cip-shape-clayBottomEl').value==='' && document.getElementById('cip-shape-casingTopEl').disabled`));
await click(`document.getElementById('cip-casing-on')`);
await set('cip-shape-clayBottomEl',ground-5);
await ev(`document.getElementById('cip-shape-clayBottomEl').dispatchEvent(new Event('change'))`);
check('casing defaults follow ground plus 3 m and bedrock; independent clay persists', await ev(`!document.getElementById('cip-shape-casingTopEl').disabled && Number(document.getElementById('cip-shape-casingTopEl').value)===${ground+3} && Number(document.getElementById('cip-shape-casingBottomEl').value)===${tip+1} && Number(document.getElementById('cip-shape-clayBottomEl').value)===${ground-5}`));
await click(`document.getElementById('member-regen')`); await shot('00-shape-decisions');
await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('기둥');
check('round-column two-layer defaults and coupler mode are populated', await ev(`document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-column-innerMainCount').value==='46' && document.getElementById('cip-column-coverReference').value==='main-center' && document.getElementById('cip-column-tieSpacingMm').value==='300' && document.getElementById('cip-connection-mode').value==='coupler'`));
await member('현장타설말뚝');
check('pile bottom cover defaults to 250 mm', await ev(`document.getElementById('cip-pile-endCoverMm').value==='250'`));
await member('기둥');
await set('cip-column-mainDiameterMm',32); await set('cip-column-mainCount',46); await set('cip-column-coverMm',100);
await set('cip-connection-mode','continuous'); await click(`document.getElementById('cip-column-apply')`);
await until(`document.getElementById('cip-column-mainCount')?.value === '46'`);
await shot('01-column-input');
await member('현장타설말뚝');
await set('cip-pile-cageLengthMm',999999); await click(`document.getElementById('cip-pile-apply')`);
check('invalid cage length rejected with inline reason', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('철근망 길이가 말뚝 길이보다')`));
await set('cip-pile-cageLengthMm',12000); await set('cip-pile-mainCount',46); await set('cip-pile-tieKind','spiral');
await click(`document.getElementById('cip-pile-apply')`);
await until(`document.getElementById('cip-pile-cageLengthMm')?.value === '12000'`);
check('pile panel shares the column connection', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`));
await shot('02-pile-input');
// ④ is no longer a header step (header ①②③, 2026-11-06): open the 재료표 from ③.
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('quantity clearly marks incomplete design', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료') && document.getElementById('quantity-body').textContent.includes('접합부')`));
await shot('03-quantity-incomplete');
await click(`document.getElementById('save-project')`);
if (await ev(`document.getElementById('save-dialog').open`)) await click(`document.getElementById('sv-save')`);
for (let i=0;i<120 && !(download && completed.has(download));i++) await delay(150);
assert.ok(download && completed.has(download), 'project download completes');
const savedText = fs.readFileSync(path.join(downloads,download),'utf8');
const saved = JSON.parse(savedText), dedicated = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
const shape = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceShape;
check('project preserves explicit shape settings without freezing automatic rock ELs',shape.casingOn===true && shape.clayBottomEl===ground-5 && shape.bedrockEl===undefined);
check('download preserves independent column, pile and connection', dedicated.column.mainCount===46 && dedicated.pile.cageLengthMm===12000 && dedicated.connection.mode==='continuous');
const savedPath = path.join(out,'saved.pzr.json'); fs.writeFileSync(savedPath,savedText);
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});
await until(`typeof window.openSampleProject === 'function'`); await ev('window.openSampleProject()');
await click(`document.getElementById('open-project')`); await file(savedPath);
await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('현장타설말뚝');
check('fresh reopen restores pile values and shared connection', await ev(`document.getElementById('cip-pile-cageLengthMm').value==='12000' && document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`));
await member('기둥');
check('fresh reopen restores independent column values', await ev(`document.getElementById('cip-column-mainCount').value==='46'`));
await click(`document.getElementById('nav-plan')`); await select('plan-pier-items','P1');
await click(`[...document.querySelectorAll('#members button')].find(b=>b.textContent.startsWith('현장타설말뚝'))`);
check('fresh reopen restores casing toggle and independent clay EL',await ev(`document.getElementById('cip-casing-on').checked && Number(document.getElementById('cip-shape-clayBottomEl').value)===${ground-5}`));
check('no runtime exceptions in actual app',report.errors.length===0);
report.result='PASS';
})().catch(e=>{ report.result='FAIL'; report.failure=e.stack; process.exitCode=1; }).finally(async()=>{
if(report.result==='FAIL' && send) try { const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64')); } catch {}
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));
console.log(JSON.stringify({result:report.result,checks:report.checks.length,errors:report.errors,failure:report.failure}));
ws?.close();
});
+121
View File
@@ -0,0 +1,121 @@
// Actual app verification in an isolated Windows Chrome, CDP port 9444.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/cast-input-20261105');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
const delay = ms => new Promise(r => setTimeout(r, ms));
let ws, send;
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0, download;
const completed = new Set(), pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
if (m.method === 'Browser.downloadWillBegin') download = m.params.guid;
if (m.method === 'Browser.downloadProgress' && m.params.state === 'completed') completed.add(m.params.guid);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 25000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => { for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); } throw new Error(`Wait failed: ${expression}`); };
const click = expression => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target: ' + ${JSON.stringify(expression)}); e.click(); })()`);
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
const file = async name => {
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [name] });
};
const shot = async name => { await delay(250); const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
const member = text => click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(text)}))`);
const select = (id, name) => click(`[...document.querySelectorAll('#${id} button')].find(b => b.textContent.startsWith(${JSON.stringify(name + ' ·')}))`);
const set = (id, value) => ev(`document.getElementById(${JSON.stringify(id)}).value = ${JSON.stringify(String(value))}`);
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
const downloads = 'C:\\Users\\User\\AppData\\Local\\Temp\\pierzainer-cast-input-20261105\\downloads';
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject === 'function'`);
const fixture = await ev(`(async () => {
const XLSX = await import('/node_modules/xlsx/xlsx.mjs');
const {parseWorkbook} = await import('/src/core/parseWorkbook.ts');
const b = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base = parseWorkbook(XLSX.read(b)).piers.find(p=>p.name==='P1');
const piers=[];
for (const [type,columnCount] of [['T형',1],['2주형',2],['3주형',3]]) for (const foundationType of ['현장타설말뚝','직접기초','말뚝기초']) {
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType};
piers.push({id:input.name,input,defaults:input,draft:input});
}
return {schemaVersion:1,project:{name:'현장타설 입력 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.getElementById('open-project')`); await file(fixturePath);
await until(`document.querySelectorAll('#plan-pier-items button').length === 9`);
for (let i = 1; i <= 9; i++) {
const cast = [1,4,7].includes(i);
await click(`document.getElementById('nav-plan')`); await select('plan-pier-items', 'P'+i);
await click(`[...document.querySelectorAll('#members button')].find(b => b.textContent.startsWith(${JSON.stringify(cast ? '현장타설말뚝' : '확대기초')}))`);
check(`P${i} plan: appropriate foundation controls`, await ev(cast
? `!!document.querySelector('#members button[disabled]') && !!document.querySelector('#member-fields #edit-pileLengthMm') && !document.querySelector('#member-fields #edit-footingHeightMm')`
: `!!document.querySelector('#member-fields #edit-footingHeightMm:not([disabled])')`));
await click(`document.getElementById('nav-rebar')`); await member('코핑');
check(`P${i} entering rebar builds coping drawings immediately`, await ev(`!!document.querySelector('#rebar-table figure svg')`));
await member('기둥');
check(`P${i} rebar: dedicated or legacy column`, await ev(cast
? `!!document.querySelector('[data-cast-part="column"]') && [...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('확대기초')).disabled`
: `!!document.getElementById('column-nDiv') && !document.querySelector('[data-cast-part]')`));
}
await select('rebar-pier-items','P1'); await member('기둥');
await set('cip-column-mainDiameterMm',32); await set('cip-column-mainCount',46); await set('cip-column-coverMm',100);
await set('cip-connection-mode','continuous'); await click(`document.getElementById('cip-column-apply')`);
await until(`document.getElementById('cip-column-mainCount')?.value === '46'`);
await shot('01-column-input');
await member('현장타설말뚝');
await set('cip-pile-cageLengthMm',999999); await click(`document.getElementById('cip-pile-apply')`);
check('invalid cage length rejected with inline reason', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('철근망 길이가 말뚝 길이보다')`));
await set('cip-pile-cageLengthMm',12000); await set('cip-pile-mainCount',46); await set('cip-pile-tieKind','spiral');
await click(`document.getElementById('cip-pile-apply')`);
await until(`document.getElementById('cip-pile-cageLengthMm')?.value === '12000'`);
check('pile panel shares the column connection', await ev(`document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`));
await shot('02-pile-input');
// ④ is no longer a header step (header ①②③, 2026-11-06): open the 재료표 from ③.
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('quantity clearly marks incomplete design', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료') && document.getElementById('quantity-body').textContent.includes('접합부')`));
await shot('03-quantity-incomplete');
await click(`document.getElementById('save-project')`);
if (await ev(`document.getElementById('save-dialog').open`)) await click(`document.getElementById('sv-save')`);
for (let i=0;i<120 && !(download && completed.has(download));i++) await delay(150);
assert.ok(download && completed.has(download), 'project download completes');
const savedText = fs.readFileSync(path.join(downloads,download),'utf8');
const saved = JSON.parse(savedText), dedicated = saved.piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
check('download preserves independent column, pile and connection', dedicated.column.mainCount===46 && dedicated.pile.cageLengthMm===12000 && dedicated.connection.mode==='continuous');
const savedPath = path.join(out,'saved.pzr.json'); fs.writeFileSync(savedPath,savedText);
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});
await until(`typeof window.openSampleProject === 'function'`); await ev('window.openSampleProject()');
await click(`document.getElementById('open-project')`); await file(savedPath);
await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
await click(`document.getElementById('nav-rebar')`); await select('rebar-pier-items','P1'); await member('현장타설말뚝');
check('fresh reopen restores pile values and shared connection', await ev(`document.getElementById('cip-pile-cageLengthMm').value==='12000' && document.querySelector('[data-cast-part="pile"]').textContent.includes('공통 연결 방식: 연속')`));
await member('기둥');
check('fresh reopen restores independent column values', await ev(`document.getElementById('cip-column-mainCount').value==='46'`));
check('no runtime exceptions in actual app',report.errors.length===0);
report.result='PASS';
})().catch(e=>{ report.result='FAIL'; report.failure=e.stack; process.exitCode=1; }).finally(async()=>{
if(report.result==='FAIL' && send) try { const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64')); } catch {}
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));
console.log(JSON.stringify({result:report.result,checks:report.checks.length,errors:report.errors,failure:report.failure}));
ws?.close();
});
+102
View File
@@ -0,0 +1,102 @@
// Actual app: isolated Windows Chrome on CDP 9444, Vite on 5173. Run with Windows Node.
const fs=require('node:fs'), path=require('node:path'), assert=require('node:assert/strict');
const out=path.resolve(__dirname,process.argv[2]||'../.verify/cast-rebar-20261106'); fs.mkdirSync(out,{recursive:true});
const report={checks:[],errors:[],started:new Date().toISOString()};
const delay=ms=>new Promise(r=>setTimeout(r,ms)); let ws,send;
(async()=>{
const target=await(await fetch('http://127.0.0.1:9444/json/new?about:blank',{method:'PUT'})).json();
ws=new WebSocket(target.webSocketDebuggerUrl); await new Promise((r,j)=>{ws.onopen=r;ws.onerror=j;});
let seq=0,download; const pending=new Map(), completed=new Set();
ws.onmessage=({data})=>{const m=JSON.parse(data),p=pending.get(m.id);if(p){clearTimeout(p.timer);pending.delete(m.id);m.error?p.reject(new Error(JSON.stringify(m.error))):p.resolve(m.result);}
if(m.method==='Runtime.exceptionThrown')report.errors.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);
if(m.method==='Browser.downloadWillBegin')download=m.params.guid;
if(m.method==='Browser.downloadProgress'&&m.params.state==='completed')completed.add(m.params.guid);
if(m.method==='Page.javascriptDialogOpening'&&m.params.type==='beforeunload')send('Page.handleJavaScriptDialog',{accept:true}).catch(e=>report.errors.push(e.message));
};
send=(method,params={})=>new Promise((resolve,reject)=>{const id=++seq,timer=setTimeout(()=>{pending.delete(id);reject(new Error('Timeout '+method));},30000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}));});
const ev=async expression=>{const r=await send('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value;};
const until=async expression=>{for(let i=0;i<120;i++){if(await ev(expression))return;await delay(150);}throw new Error('Wait: '+expression);};
const click=expression=>ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing/disabled target');e.click();})()`);
const set=(id,value)=>ev(`(()=>{const e=document.getElementById(${JSON.stringify(id)});e.value=${JSON.stringify(String(value))};e.dispatchEvent(new Event(e.tagName==='SELECT'?'change':'input',{bubbles:true}));})()`);
const check=(name,value)=>{assert.ok(value,name);report.checks.push(name);console.log('PASS',name);};
const shot=async name=>{await delay(250);const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,name+'.png'),Buffer.from(r.data,'base64'));};
const open=async file=>{await click(`document.querySelector('#file-menu summary')`);await click(`document.getElementById('open-project')`);const doc=await send('DOM.getDocument');const{nodeId}=await send('DOM.querySelector',{nodeId:doc.root.nodeId,selector:'#project-file'});await send('DOM.setFileInputFiles',{nodeId,files:[file]});};
const pier=name=>click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith(${JSON.stringify(name+' ·')}))`);
const member=text=>click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith(${JSON.stringify(text)}))`);
const table=`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].filter(r=>r.cells.length===14).map(r=>({name:r.cells[2].textContent,size:r.cells[3].textContent,grade:r.cells[4].textContent,count:Number(r.cells[11].textContent)}))`;
const downloads=path.join(out,'downloads');fs.mkdirSync(downloads,{recursive:true});
await send('Page.enable');await send('Runtime.enable');await send('DOM.enable');
await send('Browser.setDownloadBehavior',{behavior:'allowAndName',downloadPath:downloads,eventsEnabled:true});
await send('Emulation.setDeviceMetricsOverride',{width:1600,height:1000,deviceScaleFactor:1,mobile:false});
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`);
const fixture=await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1'),piers=[];
for(const[type,columnCount]of[['T형',1],['2주형',2],['3주형',3]])for(const foundationType of ['현장타설말뚝','직접기초','말뚝기초']){
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType,groundEl:30,pileLengthMm:24000,
...(foundationType==='현장타설말뚝'?{castInPlaceShape:{clayBottomEl:16},castInPlaceRebar:{column:{endZoneLengthMm:2000}}}:{})};
piers.push({id:input.name,input,defaults:input,draft:input});
}return{schemaVersion:1,project:{name:'현장타설 실배근 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'9개 합성 표본',sheets:[]},piers,history:[]};
})()`);
const file=path.join(out,'fixture.pzr.json');fs.writeFileSync(file,JSON.stringify(fixture));
await ev('window.openSampleProject()');await open(file);await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
await click(`document.getElementById('nav-rebar')`);
for(let i=1;i<=9;i++){
await pier('P'+i);await member('기둥');const cast=[1,4,7].includes(i);
const rows=await ev(table);
check(`P${i}: appropriate real main and tie rows`,cast
? ['기둥-P1','기둥-P2','말뚝-P1','말뚝-P2','기둥-P3','말뚝-P3','기둥-P5','말뚝-P5','접합-P1 커플러'].every(n=>rows.some(r=>r.name===n&&r.count>0))
: ['P1','P2','P3','P5'].every(n=>rows.some(r=>r.name===n&&r.count>0)));
check(`P${i}: list reports actual support status`,await ev(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P${i} ·')).parentElement.textContent.includes('${cast||i>=4?'일부 계산':'계산됨'}')`));
}
for(const i of [4,5,6,7,8,9]){
await pier('P'+i);await member('코핑');const rows=await ev(table);
check(`P${i}: multi drawing diameters reach actual table`,[['C1','H25'],['C7','H29'],['C8','H25'],['C10','H22'],['C12','H16'],['C14','H16']].every(([name,size])=>rows.some(r=>r.name===name&&r.size===size&&r.count>0)));
check(`P${i}: multi defaults and remaining detail notice are visible`,await ev(`document.getElementById('coping-spacingS').value==='300'&&document.getElementById('coping-sideSpacing').value==='200'&&document.getElementById('coping-c14UpperSpacing').value==='600'&&document.getElementById('rebar-issues').textContent.includes('cycle별 이음')`));
}
await shot('00-multi-coping');
await pier('P1');await member('기둥');
check('dedicated drawing uses generated front and section paths',await ev(`!!document.querySelector('[data-cast-part] figure svg polyline') && document.querySelectorAll('[data-cast-part] figure svg circle').length>50`));
check('missing bottom P6 remains explicit rather than hiding all reinforcement',await ev(`document.getElementById('rebar-issues').textContent.includes('말뚝 저부 P6 검토안 미적용')`));
await set('review-model-height',420);await shot('01-real-rebar');
const baseline=await ev(table);
await set('cip-common-mainCount',50);await set('cip-common-mainDiameterMm',35.8);await set('cip-common-mainGradeMpa',400);await click(`document.getElementById('cip-apply')`);
const edited=await ev(table);
check('shared edits regenerate physical piece counts, diameters and grades',edited.filter(r=>r.name.endsWith('-P1')).every(r=>r.count%50===0&&r.count>=50&&r.size==='Ø35.8'&&r.grade==='SD400'));
check('regeneration changes actual quantities rather than just form values',JSON.stringify(edited)!==JSON.stringify(baseline));
await set('cip-pile-tieKind','spiral');await click(`document.getElementById('cip-apply')`);
check('spiral produces its own real table rows', (await ev(table)).some(r=>r.name==='말뚝-P3 나선'&&r.count>0));
await set('cip-pile-mainCount',48);await click(`document.getElementById('cip-apply')`);
check('mismatched joints remove interface couplers and report the mismatch',(await ev(table)).every(r=>!r.name.startsWith('접합-'))&&await ev(`document.getElementById('rebar-issues').textContent.includes('불일치')`));
await set('cip-pile-mainCount',50);await click(`document.getElementById('cip-apply')`);
await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find(r=>r.cells[2].textContent==='말뚝-P1')`);
check('pile table selection highlights the corresponding generated mark',await ev(`[...document.querySelectorAll('#rebar-table tr.hl')].some(r=>r.cells[2].textContent==='말뚝-P1')`));
await click(`[...document.querySelectorAll('#rebar-table button')].find(b=>b.textContent==='선택만 보기')`);
await shot('02-pile-main-selected');
await click(`[...document.querySelectorAll('#rebar-table table.rebar-table tbody tr')].find(r=>r.cells[2].textContent==='말뚝-P1')`);
await click(`[...document.querySelectorAll('#rebar-table button')].find(b=>b.textContent==='선택만 보기')`);
await ev(`(()=>{const d=[...document.querySelectorAll('[data-cast-part] details')].find(d=>d.querySelector('summary').textContent.includes('삽도'));d.open=true;d.scrollIntoView({block:'start'});})()`);await shot('03-drawing');
for(const width of [1280,1600,1920]){
await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:false});await delay(150);
check(`header ${width} keeps one row and no horizontal overflow`,await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
}
// ④ is no longer a header step (header ①②③, 2026-11-06): open the 재료표 from ③.
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('quantity contains actual dedicated mains and reports remaining incomplete details',await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')&&document.getElementById('quantity-body').textContent.includes('기둥-P1')&&document.getElementById('quantity-body').textContent.includes('전체 수량 미완료')`));
await shot('04-quantity');
await click(`document.getElementById('nav-rebar')`);await member('기둥');const beforeSave=await ev(table);
await click(`document.getElementById('save-project')`);if(await ev(`document.getElementById('save-dialog').open`))await click(`document.getElementById('sv-save')`);
for(let i=0;i<120&&!(download&&completed.has(download));i++)await delay(150);assert.ok(download&&completed.has(download),'download completes');
const text=fs.readFileSync(path.join(downloads,download),'utf8'),savedPath=path.join(out,'saved.pzr.json');fs.writeFileSync(savedPath,text);
const saved=JSON.parse(text).piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
check('download preserves generating inputs',saved.column.mainCount===50&&saved.pile.mainCount===50&&saved.pile.tieKind==='spiral'&&saved.column.mainGradeMpa===400);
await send('Page.navigate',{url:'http://127.0.0.1:5173/'});await until(`typeof window.openSampleProject==='function'`);await ev('window.openSampleProject()');await open(savedPath);await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
await click(`document.getElementById('nav-rebar')`);await pier('P1');await member('기둥');
check('fresh-page restore regenerates identical physical bar rows',JSON.stringify(await ev(table))===JSON.stringify(beforeSave));
check('no runtime exceptions',report.errors.length===0);report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(async()=>{
if(report.result==='FAIL'&&send)try{const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64'));}catch{}
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));console.log(JSON.stringify({result:report.result,checks:report.checks.length,failure:report.failure}));ws?.close();
});
+130
View File
@@ -0,0 +1,130 @@
// CDP regression checks for the four scoped design improvements. Run with Windows Node.
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const root = path.resolve(__dirname, '..');
const out = path.join(root, '.verify', 'design-fixes-20261105');
const report = { checks: [], errors: [], captures: [], started: new Date().toISOString() };
fs.mkdirSync(out, { recursive: true });
let ws;
let send;
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
const target = await (await fetch('http://127.0.0.1:9444/json/new?http://127.0.0.1:5173/', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let seq = 0;
const pending = new Map();
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq;
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`CDP timeout ${method}`)); }, 20000);
pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ id, method, params }));
});
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
if (msg.id) {
const wait = pending.get(msg.id);
if (!wait) return;
clearTimeout(wait.timer); pending.delete(msg.id);
if (msg.error) wait.reject(new Error(JSON.stringify(msg.error))); else wait.resolve(msg.result);
} else if (msg.method === 'Runtime.exceptionThrown') report.errors.push(msg.params.exceptionDetails.exception?.description || msg.params.exceptionDetails.text);
else if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') report.errors.push(msg.params.args.map((a) => a.value ?? a.description).join(' '));
};
const ev = async (expression) => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async (expression) => {
for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error(`Timeout: ${expression}`);
};
const click = (expression) => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled control'); e.click(); })()`);
const shot = async (name) => {
await delay(200);
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, `${name}.png`), Buffer.from(r.data, 'base64'));
report.captures.push(name);
};
const check = (name, condition) => { assert.ok(condition, name); report.checks.push(name); console.log('PASS', name); };
const upload = async () => {
const doc = await send('DOM.getDocument');
const node = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#file' });
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [] });
await send('DOM.setFileInputFiles', { nodeId: node.nodeId, files: [path.resolve(root, '../../RAG/운곡교P1입력.xlsx')] });
await until(`!document.getElementById('dlg-apply').disabled && document.getElementById('import-abut-A1')?.checked === true`);
};
const member = async (label) => { await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(label)}))`); await delay(100); };
const lower = () => ev(`document.querySelector('#review-workspace > section').getBoundingClientRect().height`);
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await until(`!!document.querySelector('[data-card="excel"] button') && typeof window.loadBytesToApp === 'function'`);
await click(`document.querySelector('[data-card="excel"] button')`);
await ev(`document.getElementById('project-name').value = '디자인 개선 검증'; document.getElementById('project-name').dispatchEvent(new Event('input'));`);
await upload();
check('combined import scope', await ev(`document.getElementById('dlg-apply').textContent === '교각 1기 + 교대 1기 가져오기'`));
check('A1 source and dimensions preview', await ev(`document.getElementById('pv-abuts').textContent.includes('A1') && document.getElementById('pv-abuts').textContent.includes('12.197') && document.querySelector('#sheets > li:first-child').textContent.includes('교대 인식 결과')`));
await shot('01-import-both');
await click(`document.getElementById('import-abut-A1')`);
check('uncheck updates preview and count', await ev(`document.getElementById('dlg-apply').textContent.includes('교대 0기') && document.getElementById('pv-abuts').textContent.includes('아니오')`));
await click(`document.getElementById('dlg-apply')`);
check('unchecked abutment is not applied', await ev(`![...document.querySelectorAll('#pier-items button')].some(b => b.textContent.includes('교대'))`));
await click(`document.getElementById('open-import')`);
await upload();
await click(`document.getElementById('dlg-apply')`);
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('A1') && b.textContent.includes('교대'))`);
await member('확대기초');
check('A1 footing excludes pier review questions', await ev(`document.getElementById('review-summary').textContent.includes('A1') && document.getElementById('review-summary').textContent.includes('미결 0건') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
await delay(4500); await shot('02-workspace-desktop');
await send('Emulation.setDeviceMetricsOverride', { width: 1280, height: 800, deviceScaleFactor: 1, mobile: false });
await delay(300);
if (await ev(`document.getElementById('review-model-toggle').getAttribute('aria-expanded') === 'false'`)) await click(`document.getElementById('review-model-toggle')`);
await ev(`const size = document.getElementById('review-model-height'); size.value = size.max; size.dispatchEvent(new Event('input'));`);
await delay(200);
report.layoutBefore = await lower();
await ev(`const range = document.getElementById('review-model-height'); range.value = '180'; range.dispatchEvent(new Event('input'));`);
await delay(200);
report.layoutResized = await lower();
check('height control expands evidence area', report.layoutResized >= report.layoutBefore);
check('compact viewer tools stay inside model', await ev(`document.querySelector('.viewer-stack-right').getBoundingClientRect().bottom <= document.getElementById('viewer-block').getBoundingClientRect().bottom + 1`));
await click(`document.getElementById('review-model-toggle')`);
await delay(200);
report.layoutCollapsed = await lower();
check('collapse adds model area to evidence', report.layoutCollapsed > report.layoutResized + 150);
await click(`document.getElementById('review-table')`);
check('table shortcut focuses and reveals table', await ev(`document.activeElement.matches('#rebar-table table.rebar-table') && document.activeElement.getBoundingClientRect().top >= document.querySelector('#review-workspace > section').getBoundingClientRect().top - 2`));
await shot('03-table-laptop');
await click(`document.getElementById('review-inputs')`);
check('input shortcut preserves keyboard tab order', await ev(`document.activeElement.matches('#rebar-table input') && document.activeElement.tabIndex === 0`));
await shot('04-inputs-laptop');
check('layout preference persisted', await ev(`JSON.parse(localStorage.getItem('pierzainer.review-layout')).collapsed === true`));
await click(`document.getElementById('review-model-toggle')`);
await member('받침블럭');
check('block review is scoped', await ev(`document.getElementById('review-tasks').textContent.includes('Q-S4') && !document.getElementById('review-tasks').textContent.includes('Q-C9')`));
await click(`document.querySelector('#review-tasks button')`);
check('review action selects correct bar and row', await ev(`document.querySelector('#rebar-table .rebar-select').textContent.includes('선택 1 /') && document.activeElement.matches('tr') && document.activeElement.textContent.includes('S1')`));
await member('날개벽');
check('wing review shows only relevant unresolved work', await ev(`document.getElementById('review-summary').textContent.includes('미결 2건') && document.getElementById('review-tasks').textContent.includes('E19') && document.getElementById('review-tasks').textContent.includes('V-A2')`));
await shot('05-review-and-materials');
report.fields = await ev(`[...document.querySelectorAll('.material-fields .mat')].map(label => { const r = label.getBoundingClientRect(); const c = label.querySelector('input,select').getBoundingClientRect(); return { label: label.textContent, left: r.left, top: r.top, width: r.width, controlLeft: c.left, controlRight: c.right, bottom: r.bottom }; })`);
check('material fields align without overlap', report.fields.every((f, i, list) => f.controlRight <= f.left + f.width + 1 && (i === 0 || f.top >= list[i - 1].bottom)));
check('document has no horizontal overflow at laptop width', await ev('document.documentElement.scrollWidth === innerWidth'));
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b => b.textContent.includes('P1') && !b.textContent.includes('교대'))`);
await member('기둥');
check('existing decision is not presented as unresolved', await ev(`document.getElementById('review-tasks').textContent.includes('Q-C9') && document.getElementById('review-tasks').textContent.includes('결정됨') && document.getElementById('review-summary').textContent.includes('미결 0건')`));
check('no runtime errors', report.errors.length === 0);
report.result = 'PASS';
}
main().catch((error) => { report.result = 'FAIL'; report.failure = error.stack; process.exitCode = 1; }).finally(async () => {
if (report.result === 'FAIL' && send) {
try { const shot = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, 'failure.png'), Buffer.from(shot.data, 'base64')); } catch { /* preserve original failure */ }
}
report.finished = new Date().toISOString();
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify({ result: report.result, checks: report.checks.length, errors: report.errors, failure: report.failure }));
if (send) { try { await send('Page.close'); } catch { /* tab may already be closed */ } }
ws?.close();
});
+83
View File
@@ -0,0 +1,83 @@
// Run with Windows Node and an isolated Chrome on CDP 9444; Vite must be on 5173.
const fs = require('node:fs'), path = require('node:path'), assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/merge-20261106');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [] };
let ws, send;
const delay = ms => new Promise(r => setTimeout(r, ms));
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; });
let seq = 0; const pending = new Map();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => reject(new Error('Timeout ' + method)), 30000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => {
for (let i = 0; i < 120; i++) { if (await ev(expression)) return; await delay(150); }
throw new Error('Wait ' + expression);
};
const check = (name, value) => { assert.ok(value, name); report.checks.push(name); console.log('PASS', name); };
const click = expression => ev(`(()=>{const e=${expression};if(!e||e.disabled)throw new Error('Missing target');e.click();})()`);
const shot = async name => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64'));
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async()=>{
const XLSX=await import('/node_modules/xlsx/xlsx.mjs'),{parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await(await fetch('/'+encodeURIComponent('도면추출')+'/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
if(!base)throw new Error('Missing sample pier');
const piers=['직접기초','현장타설말뚝'].map((foundationType,i)=>{const input={...base,name:'P'+(i+1),foundationType,type:'T형',columnCount:1,groundEl:30,pileLengthMm:24000,castInPlaceShape:{clayBottomEl:16}};return{id:input.name,input,defaults:input,draft:input};});
return{schemaVersion:1,project:{name:'병합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
const file = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(file, JSON.stringify(fixture));
await ev('window.openSampleProject()');
await click(`document.querySelector('#file-menu summary')`); await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [file] });
await until(`document.querySelectorAll('#rebar-pier-items button').length===2`);
await click(`document.getElementById('nav-rebar')`);
check('local rebar screen renders', await ev(`!document.getElementById('view-rebar').hidden&&!!document.querySelector('#rebar-table table')`));
await click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith('P2 ·'))`);
await click(`[...document.querySelectorAll('#rebar-tabs button')].find(b=>b.textContent.startsWith('기둥'))`);
check('unified cast input and physical rebar preserved', await ev(`!!document.getElementById('cip-apply')&&document.getElementById('rebar-table').textContent.includes('말뚝-P1')`));
await shot('01-cast');
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('quantity workspace from origin renders', await ev(`!document.getElementById('view-quantity').hidden&&!!document.querySelector('.takeoff-workspace .takeoff-toolbar')`));
await click(`document.querySelector('[data-takeoff-mode="materials"]')`);
check('local material table preserved', await ev(`document.getElementById('quantity-body').textContent.includes('말뚝-P1')`));
await shot('02-materials');
await click(`document.getElementById('nav-drawing')`);
await until(`!!document.querySelector('#view-drawing svg')`);
check('origin drawing screen renders finite geometry', await ev(`!document.getElementById('view-drawing').hidden&&!/NaN|Infinity/.test(document.querySelector('#view-drawing svg').outerHTML)`));
await shot('03-drawing');
await click(`document.getElementById('nav-plan')`);
check('plan remains reachable after drawing', await ev(`!document.getElementById('view-plan').hidden`));
for (const width of [1280, 1600, 1920]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false });
await delay(150);
check(`one-row header preserved at ${width}`, await ev(`document.querySelector('.top').getBoundingClientRect().height<=65&&document.documentElement.scrollWidth<=innerWidth`));
}
check('no runtime exceptions', report.errors.length === 0);
report.result = 'PASS';
})().catch(e => { report.result = 'FAIL'; report.failure = e.stack; process.exitCode = 1; }).finally(() => {
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(report, null, 2));
console.log(JSON.stringify(report)); ws?.close();
});
+77
View File
@@ -0,0 +1,77 @@
// Run with Windows Node against the task's isolated Chrome CDP on port 9444.
const fs = require('node:fs');
const path = require('node:path');
const out = path.resolve(__dirname, '../.verify/multicol-coping-20261105');
fs.mkdirSync(out, { recursive: true });
(async () => {
const targets = await (await fetch('http://127.0.0.1:9444/json')).json();
const page = targets.find(t => t.type === 'page' && t.url.startsWith('http://127.0.0.1:5173'));
if (!page) throw new Error('Task browser page missing');
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let seq = 0;
const pending = new Map();
ws.onmessage = ({ data }) => {
const msg = JSON.parse(data);
const p = pending.get(msg.id);
if (p) { pending.delete(msg.id); clearTimeout(p.timer); msg.error ? p.reject(new Error(JSON.stringify(msg.error))) : p.resolve(msg.result); }
};
const send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq;
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 20000);
pending.set(id, { resolve, reject, timer });
ws.send(JSON.stringify({ id, method, params }));
});
try {
await send('Emulation.setDeviceMetricsOverride', { width: 1200, height: 750, deviceScaleFactor: 1, mobile: false });
const results = [];
for (const type of ['2주형', '3주형']) {
const res = await send('Runtime.evaluate', { awaitPromise: true, returnByValue: true, expression: `
(async () => {
const THREE = await import('/node_modules/.vite/deps/three.js');
const XLSX = await import('/node_modules/xlsx/xlsx.mjs');
const { parseWorkbook } = await import('/src/core/parseWorkbook.ts');
const { buildPier } = await import('/src/core/geometry.ts');
const { buildPierScene } = await import('/src/view/scene.ts');
const buf = await (await fetch('/samples/' + encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base = parseWorkbook(XLSX.read(buf)).piers.find(p => p.name === 'P1');
if (!base) throw new Error('P1 sample missing');
// 3주형 needs a 20 m coping for the 오목형 spans (P1 length leaves no flat part, RB-M01).
const m = buildPier({ ...base, ...(${type === '3주형'} ? { copingLengthMm: 20000 } : {}), type: ${JSON.stringify(type)}, columnCount: ${type === '2주형' ? 2 : 3} });
const built = buildPierScene(m);
const scene = new THREE.Scene(); scene.background = new THREE.Color(0xe9eef4);
const group = new THREE.Group();
group.add(built.parts.coping, built.parts.columns); scene.add(group);
const center = new THREE.Vector3();
const bounds = new THREE.Box3().setFromObject(built.parts.coping);
bounds.getCenter(center);
// Align the camera along the cross-axis to make both interior haunches visible.
const u = new THREE.Vector3(m.u.e, 0, -m.u.n);
const cross = new THREE.Vector3(m.nrm.e, 0, -m.nrm.n);
const camera = new THREE.PerspectiveCamera(36, 1200/750, 0.1, 1000);
const target = center.clone().add(new THREE.Vector3(0, -0.9, 0));
camera.position.copy(target).addScaledVector(cross, m.copingL * 1.6).addScaledVector(u, -m.copingL * 0.12).add(new THREE.Vector3(0,-2.5,0));
camera.lookAt(target);
scene.add(new THREE.HemisphereLight(0xffffff, 0x687784, 2.4));
const light = new THREE.DirectionalLight(0xffffff, 2); light.position.copy(camera.position).add(new THREE.Vector3(0,10,0)); scene.add(light);
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
renderer.setSize(1200,750); renderer.localClippingEnabled = true;
document.body.replaceChildren(renderer.domElement); document.body.style.margin = '0';
renderer.render(scene,camera);
const profile = m.copingParts[0].profile;
const raised = profile.filter(([,z]) => Math.abs(z-m.levelBottom-0.5)<1e-8).length;
if (raised !== (m.columns.length-1)*2) throw new Error('Raised span count mismatch');
if (Math.abs(Math.min(...profile.map(p=>p[1]))-m.levelBottom)>1e-8) throw new Error('Underside below column Level');
if (!m.columns.every(c=>c.top===m.levelBottom)) throw new Error('Column connection changed');
return { type: ${JSON.stringify(type)}, raisedVertices: raised, levelBottom:m.levelBottom, interiorBottom:m.levelBottom+0.5, columns:m.columns.length, profile, screenshot:renderer.domElement.toDataURL('image/png') };
})()
` });
if (res.exceptionDetails) throw new Error(res.exceptionDetails.exception?.description || res.exceptionDetails.text);
const { screenshot, ...result } = res.result.value;
fs.writeFileSync(path.join(out, `${type}.png`), Buffer.from(screenshot.split(',')[1], 'base64'));
results.push(result);
}
fs.writeFileSync(path.join(out, 'report.json'), JSON.stringify(results, null, 2));
console.log(JSON.stringify(results.map(({ profile, ...r }) => r)));
} finally { ws.close(); }
})().catch(e => { console.error(e); process.exitCode = 1; });
-43
View File
@@ -1,43 +0,0 @@
// Clicks the template download button in the import dialog through Chrome DevTools
// and reports the saved file. Run with Windows Node (same machine as Chrome):
// node verify-template-download.cjs "C:\\Users\\...\\Temp\\verify-downloads"
const fs = require('fs');
const path = require('path');
const DL_DIR = process.argv[2];
const CDP = 'http://127.0.0.1:9444';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function main() {
fs.mkdirSync(DL_DIR, { recursive: true });
const page = (await (await fetch(`${CDP}/json`)).json()).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();
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); }
};
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, 300));
return r.result.result.value;
};
await send('Page.enable');
await send('Runtime.enable');
await send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: DL_DIR, eventsEnabled: false });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await sleep(4000);
await evalJs(`document.getElementById('open-import').click()`);
await sleep(500);
await evalJs(`document.getElementById('download-template').click()`);
await sleep(4000);
const files = fs.readdirSync(DL_DIR);
console.log('DOWNLOADED', JSON.stringify(files));
for (const f of files) console.log('SIZE', f, fs.statSync(path.join(DL_DIR, f)).size);
ws.close();
}
main().catch((e) => { console.error('FAILED', e); process.exit(1); });
-113
View File
@@ -1,113 +0,0 @@
// UI verification driven through the Chrome DevTools Protocol.
// Run with Windows Node (same machine as Chrome), e.g.:
// node verify-ui.cjs "C:\\Users\\...\\input.xlsx" "\\\\wsl.localhost\\Ubuntu\\...\\shots"
// Chrome must be started with --remote-debugging-port=9444 (see DOC/05_검증).
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 = process.argv[2];
const OUT_DIR = process.argv[3];
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');
if (!page) throw new Error('no page target');
const ws = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((res, rej) => {
ws.onopen = res;
ws.onerror = rej;
});
let seq = 0;
const waiting = new Map();
// Console and exception events, collected from the first navigation onward (V10 ③).
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.consoleAPICalled' && ['error', 'warning'].includes(m.params.type)) {
const text = m.params.args.map((a) => a.value ?? a.description ?? '').join(' ');
consoleEvents.push(`console.${m.params.type}: ${text}`);
} else if (m.method === 'Runtime.exceptionThrown') {
const d = m.params.exceptionDetails;
consoleEvents.push(`exception: ${d.exception?.description || d.text}`);
} else if (m.method === 'Log.entryAdded' && ['error', 'warning'].includes(m.params.entry.level)) {
consoleEvents.push(`log.${m.params.entry.level}: ${m.params.entry.text} ${m.params.entry.url || ''}`.trim());
}
};
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) => {
const r = await send('Page.captureScreenshot', { format: 'png' });
fs.writeFileSync(path.join(OUT_DIR, name), Buffer.from(r.result.data, 'base64'));
console.log(`SHOT ${name}`);
};
await send('Page.enable');
await send('Runtime.enable');
await send('Log.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);
// V11 — dialog with the user's three-sheet workbook.
await evalJs(`document.getElementById('open-import').click()`);
await sleep(500);
await evalJs(`(() => { const i = document.getElementById('project-name'); i.value = '운곡교 검증';
i.dispatchEvent(new Event('input')); return true; })()`);
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);
console.log('SHEETS_TEXT_BEGIN');
console.log(await evalJs(`document.getElementById('sheets').innerText`));
console.log('SHEETS_TEXT_END');
console.log('SELECTED', await evalJs(`document.getElementById('dlg-selected').textContent`));
console.log('APPLY', await evalJs(`(() => { const b = document.getElementById('dlg-apply');
return b.textContent + ' | disabled=' + b.disabled; })()`));
await shot('v11_dialog_user_file.png');
await evalJs(`document.getElementById('dlg-apply').click()`);
await sleep(2000);
console.log('STATUS', await evalJs(`document.getElementById('status').textContent`));
console.log('PIERS', await evalJs(`[...document.querySelectorAll('#pier-items li')].map(li => li.textContent.trim()).join(' | ')`));
console.log('RESULT_AXIS', await evalJs(`document.getElementById('res-axis').innerText.replace(/\\n+/g, ' / ')`));
console.log('RESULT_FOUND', await evalJs(`document.getElementById('res-found').innerText.replace(/\\n+/g, ' / ')`));
await shot('v11_main_user_file.png');
// V10 ② — the four view presets.
for (const label of ['정면', '측면', '평면', '아이소']) {
await evalJs(`[...document.querySelectorAll('#views button')].find((b) => b.textContent === '${label}').click()`);
await sleep(1500);
await shot(`v10_view_${label}.png`);
console.log('PRESSED', label, await evalJs(`[...document.querySelectorAll('#views button')].filter((b) => b.getAttribute('aria-pressed') === 'true').map((b) => b.textContent).join(',')`));
}
// V10 ③ — every console error/warning and uncaught exception seen during the run above.
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);
});
+151
View File
@@ -0,0 +1,151 @@
// Run with Windows Node against an isolated Chrome CDP session (port 9444).
const fs = require('node:fs');
const path = require('node:path');
const assert = require('node:assert/strict');
const out = path.resolve(__dirname, '../.verify/unified-input-20261106');
fs.mkdirSync(out, { recursive: true });
const report = { checks: [], errors: [], layouts: [] };
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
let ws, send;
(async () => {
const target = await (await fetch('http://127.0.0.1:9444/json/new?about:blank', { method: 'PUT' })).json();
ws = new WebSocket(target.webSocketDebuggerUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let seq = 0, download;
const pending = new Map(), completed = new Set();
ws.onmessage = ({ data }) => {
const m = JSON.parse(data), p = pending.get(m.id);
if (p) { clearTimeout(p.timer); pending.delete(m.id); m.error ? p.reject(new Error(JSON.stringify(m.error))) : p.resolve(m.result); }
if (m.method === 'Runtime.exceptionThrown') report.errors.push(m.params.exceptionDetails.exception?.description || m.params.exceptionDetails.text);
if (m.method === 'Browser.downloadWillBegin') download = m.params.guid;
if (m.method === 'Browser.downloadProgress' && m.params.state === 'completed') completed.add(m.params.guid);
};
send = (method, params = {}) => new Promise((resolve, reject) => {
const id = ++seq, timer = setTimeout(() => { pending.delete(id); reject(new Error(`Timeout ${method}`)); }, 25000);
pending.set(id, { resolve, reject, timer }); ws.send(JSON.stringify({ id, method, params }));
});
const ev = async expression => {
const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text);
return r.result.value;
};
const until = async expression => { for (let i = 0; i < 100; i++) { if (await ev(expression)) return; await delay(150); } throw new Error(`Wait failed: ${expression}`); };
const click = expression => ev(`(() => { const e = ${expression}; if (!e || e.disabled) throw new Error('Missing/disabled target'); e.click(); })()`);
const check = (name, pass) => { assert.ok(pass, name); report.checks.push(name); console.log('PASS', name); };
const set = (id, value) => ev(`(() => {const e=document.getElementById(${JSON.stringify(id)}); e.value=${JSON.stringify(String(value))}; e.dispatchEvent(new Event(e.tagName==='SELECT'?'change':'input',{bubbles:true}));})()`);
const shot = async name => { const r = await send('Page.captureScreenshot', { format: 'png' }); fs.writeFileSync(path.join(out, name + '.png'), Buffer.from(r.data, 'base64')); };
const member = text => click(`[...document.querySelectorAll('#rebar-tabs button')].find(b => b.textContent.startsWith(${JSON.stringify(text)}))`);
const selectPier = name => click(`[...document.querySelectorAll('#rebar-pier-items button')].find(b=>b.textContent.startsWith(${JSON.stringify(name+' ·')}))`);
const open = async name => {
await click(`document.querySelector('#file-menu summary')`);
await click(`document.getElementById('open-project')`);
const doc = await send('DOM.getDocument');
const { nodeId } = await send('DOM.querySelector', { nodeId: doc.root.nodeId, selector: '#project-file' });
await send('DOM.setFileInputFiles', { nodeId, files: [name] });
};
await send('Page.enable'); await send('Runtime.enable'); await send('DOM.enable');
await send('Emulation.setDeviceMetricsOverride', { width: 1600, height: 1000, deviceScaleFactor: 1, mobile: false });
const downloads = path.join(out, 'downloads'); fs.mkdirSync(downloads, { recursive: true });
await send('Browser.setDownloadBehavior', { behavior: 'allowAndName', downloadPath: downloads, eventsEnabled: true });
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' });
await until(`typeof window.openSampleProject==='function'`);
const fixture = await ev(`(async () => {
const XLSX=await import('/node_modules/xlsx/xlsx.mjs');
const {parseWorkbook}=await import('/src/core/parseWorkbook.ts');
const bytes=await (await fetch('/samples/'+encodeURIComponent('운곡교P1입력.xlsx'))).arrayBuffer();
const base=parseWorkbook(XLSX.read(bytes)).piers.find(p=>p.name==='P1');
const piers=[];
for(const [type,columnCount] of [['T형',1],['2주형',2],['3주형',3]]) for(const foundationType of ['현장타설말뚝','직접기초','말뚝기초']) {
const input={...base,name:'P'+(piers.length+1),type,columnCount,foundationType};
piers.push({id:input.name,input,defaults:input,draft:input});
}
return {schemaVersion:1,project:{name:'입력 통합 검증',bridge:'검증',client:'',author:'',crs:'',memo:''},source:{fileName:'합성 표본',sheets:[]},piers,history:[]};
})()`);
fixture.project.name = '아주 긴 프로젝트명으로 말줄임과 한 줄 헤더를 검증하는 프로젝트';
fixture.project.bridge = fixture.project.name;
fixture.piers[0].input.castInPlaceRebar = {
column: { mainCount: 46, mainDiameterMm: 32, coverMm: 100, coverReference: 'main-center' },
pile: { mainCount: 48, mainDiameterMm: 32, coverMm: 100, coverReference: 'clear', tieKind: 'spiral', tieSpacingMm: 200, cageLengthMm: 12000 },
connection: { mode: 'coupler', couplerLengthMm: 165 },
};
fixture.piers[0].draft = fixture.piers[0].input;
const fixturePath = path.join(out, 'fixture.pzr.json'); fs.writeFileSync(fixturePath, JSON.stringify(fixture));
await ev('window.openSampleProject()'); await open(fixturePath);
await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
await click(`document.getElementById('nav-rebar')`);
for (let i = 1; i <= 9; i++) {
const cast = [1, 4, 7].includes(i);
await selectPier('P'+i); await member('기둥');
check(`P${i}: correct foundation-specific member list and editor`, await ev(cast
? `document.querySelectorAll('#rebar-tabs button').length===3 && !!document.querySelector('[data-cast-part="combined"]') && ![...document.querySelectorAll('#rebar-tabs button')].some(b=>b.textContent.includes('확대기초'))`
: `!!document.getElementById('column-nDiv') && [...document.querySelectorAll('#rebar-tabs button')].some(b=>b.textContent.startsWith('확대기초')) && !document.querySelector('[data-cast-part]')`));
}
await selectPier('P4'); await member('기둥');
check('equal defaults show one shared value with exceptions collapsed', await ev(`!document.getElementById('cip-exceptions').open && document.getElementById('cip-common-mainCount').value==='46'`));
await set('cip-common-coverMm', 120); await set('cip-common-coverReference', 'clear'); await click(`document.getElementById('cip-apply')`);
check('explicit common cover and reference apply to both segments', await ev(`document.getElementById('cip-column-coverMm').value==='120' && document.getElementById('cip-pile-coverMm').value==='120' && document.getElementById('cip-column-coverReference').value==='clear' && document.getElementById('cip-pile-coverReference').value==='clear'`));
await selectPier('P1'); await member('기둥');
check('unequal counts and equal cover numbers with different references remain distinct', await ev(`document.getElementById('cip-exceptions').open && document.getElementById('cip-common-mainCount').value==='' && document.getElementById('cip-common-coverMm').value==='' && document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-pile-mainCount').value==='48'`));
check('one connection editor and one save button', await ev(`document.querySelectorAll('#cip-connection-mode').length===1 && document.querySelectorAll('#cip-apply').length===1`));
await set('cip-common-innerMainDiameterMm', 35.8); await click(`document.getElementById('cip-apply')`);
check('shared edit updates both segments and preserves unrelated exceptions', await ev(`document.getElementById('cip-column-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-pile-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-column-mainCount').value==='46' && document.getElementById('cip-pile-mainCount').value==='48' && document.getElementById('cip-pile-coverReference').value==='clear' && document.getElementById('cip-column-coverReference').value==='main-center'`));
await set('cip-pile-cageLengthMm', 999999); await click(`document.getElementById('cip-apply')`);
check('invalid cage length is rejected inline', await ev(`document.querySelector('[data-cast-part]').textContent.includes('철근망 길이가 말뚝 길이보다')`));
await set('cip-pile-cageLengthMm', 12000); await set('cip-common-mainCount', 46.5); await click(`document.getElementById('cip-apply')`);
check('fractional main counts are rejected', await ev(`document.querySelector('[role="status"]').textContent!==undefined && document.querySelector('[data-cast-part]').textContent.includes('양수 정수')`));
await set('cip-common-mainCount', 50); await set('cip-pile-mainCount', 52); await set('cip-connection-mode', 'continuous');
await click(`document.getElementById('cip-apply')`);
check('explicit pile exception overrides a shared edit without changing column', await ev(`document.getElementById('cip-column-mainCount').value==='50' && document.getElementById('cip-pile-mainCount').value==='52' && document.getElementById('cip-pile-tieSpacingMm').value==='200' && document.getElementById('cip-connection-couplerLengthMm').value==='165'`));
check('pending review remains visible', await ev(`document.getElementById('review-tasks').textContent.includes('CP-F1') && document.getElementById('nav-rebar').getAttribute('aria-label').includes('현장타설 배근 미확정')`));
await shot('01-unified-input');
for (const width of [1280,1600,1920]) {
await send('Emulation.setDeviceMetricsOverride', { width, height: 1000, deviceScaleFactor: 1, mobile: false }); await delay(200);
const layout = await ev(`(() => {const h=document.querySelector('.top'),r=h.getBoundingClientRect();return {width:innerWidth,height:r.height,overflow:document.documentElement.scrollWidth>innerWidth,rows:[...h.children].map(e=>Math.round(e.getBoundingClientRect().top)),project:document.getElementById('project-line').textContent};})()`);
report.layouts.push(layout);
check(`header ${width}: single row and no document overflow`, layout.height<=65 && !layout.overflow);
await shot('header-'+width);
}
check('duplicate project/bridge name is omitted while full title remains available', await ev(`document.getElementById('project-line').title===${JSON.stringify(fixture.project.name+' · 교각 9기')}`));
await click(`document.querySelector('#file-menu summary')`);
check('file menu opens', await ev(`document.getElementById('file-menu').open`));
await ev(`document.getElementById('open-project').focus()`);
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
check('Escape closes the menu and restores summary focus', await ev(`!document.getElementById('file-menu').open && document.activeElement===document.querySelector('#file-menu summary')`));
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Enter', code: 'Enter', text: '\r', windowsVirtualKeyCode: 13 });
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13 });
check('keyboard Enter opens the native file menu', await ev(`document.getElementById('file-menu').open`));
await send('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
await send('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 });
check('Tab reaches the Excel import action', await ev(`document.activeElement.id==='open-import'`));
await click(`document.getElementById('open-import')`);
check('Excel action opens the existing import dialog and closes the menu', await ev(`!document.getElementById('file-menu').open && !!document.querySelector('dialog[open]')`));
await ev(`document.querySelector('dialog[open]').close()`);
await click(`document.getElementById('save-project')`);
if (await ev(`document.getElementById('save-dialog').open`)) await click(`document.getElementById('sv-save')`);
for (let i=0;i<120 && !(download && completed.has(download));i++) await delay(150);
assert.ok(download && completed.has(download), 'download completes');
const savedText = fs.readFileSync(path.join(downloads,download), 'utf8');
const saved = JSON.parse(savedText).piers.find(p=>p.input.name==='P1').input.castInPlaceRebar;
check('download retains independent segment settings and connection schema', saved.column.mainCount===50 && saved.pile.mainCount===52 && saved.pile.coverReference==='clear' && saved.column.coverReference==='main-center' && saved.connection.mode==='continuous' && !saved.common);
const savedPath = path.join(out,'saved.pzr.json'); fs.writeFileSync(savedPath,savedText);
await send('Page.navigate', { url: 'http://127.0.0.1:5173/' }); await until(`typeof window.openSampleProject==='function'`);
await ev('window.openSampleProject()'); await open(savedPath); await until(`document.querySelectorAll('#rebar-pier-items button').length===9`);
await click(`document.getElementById('nav-rebar')`); await selectPier('P1'); await member('기둥');
check('fresh-page restore retains exceptions and shared changes', await ev(`document.getElementById('cip-exceptions').open && document.getElementById('cip-column-mainCount').value==='50' && document.getElementById('cip-pile-mainCount').value==='52' && document.getElementById('cip-pile-innerMainDiameterMm').value==='35.8' && document.getElementById('cip-connection-mode').value==='continuous'`));
// Follow dedicated warnings to verify both segment targets resolve to one card.
for (const text of ['RB-CIP01', 'RB-CIP02', 'RB-F00']) {
await click(`[...document.querySelectorAll('#rebar-issues li')].find(e=>e.textContent.includes(${JSON.stringify(text)}))?.querySelector('button')`);
check(`${text}: warning opens unified editor`, await ev(`!!document.getElementById('cip-apply') && document.querySelector('#rebar-tabs button[aria-pressed="true"]').textContent.startsWith('기둥·말뚝')`));
}
// ④ is no longer a header step (header ①②③, 2026-11-06): open the 재료표 from ③.
await click(`document.getElementById('nav-rebar')`);
await click(`[...document.querySelectorAll('button')].find(b=>b.textContent==='철근재료표 보기')`);
check('quantity remains explicitly incomplete', await ev(`document.getElementById('quantity-body').textContent.includes('전체 수량 미완료')`));
check('no runtime exceptions', report.errors.length===0);
report.result='PASS';
})().catch(e=>{report.result='FAIL';report.failure=e.stack;process.exitCode=1;}).finally(async()=>{
if(report.result==='FAIL' && send) try {const r=await send('Page.captureScreenshot',{format:'png'});fs.writeFileSync(path.join(out,'failure.png'),Buffer.from(r.data,'base64'));}catch{}
fs.writeFileSync(path.join(out,'report.json'),JSON.stringify(report,null,2));
console.log(JSON.stringify({result:report.result,checks:report.checks.length,failure:report.failure}));
ws?.close();
});
+192
View File
@@ -0,0 +1,192 @@
/**
* 교대 받침 카드 (2026-11-03, 시안 ② 교량받침·받침블럭·솔플레이트, 교각 카드와 같은 형식). 순수 함수.
*
* - 솔플레이트 (결정 2026-11-06, spec_abut §5·QA-15, src/core/sole-plate.ts): S = 같은 거더의 짝 받침(A1 ↔ P1 전열,
* A2 ↔ Pn)으로 계산, 짝이 없으면 교대 종단경사 i(엑셀 Abut). L = A + 100, W = B + 100 (A × B 기본 700 × 640, 교각 P1
* 전열 값 가정). 받침이 1열이라 T는 교대 전체에 하나: max(22, ⌈22 + L·|S|/2⌉). 거더 하면 고정 → 블럭 상면 = z − (T − T_ref).
* - h3 기본값 = 받침 시트의 받침높이 − T_ref (A1 275 − 30 = 245): 엑셀 받침높이가 정한 거더 하면을 그대로 둔다
* (시안은 교각 P1의 h3 181을 가정했으나 교대 시트에 받침높이가 있어 그 값을 지킨다).
* - 높이: E = h1 (블럭 콘크리트) + h2 (몰탈패드 50) + h3 + h4 (= T). z = 패드 상면 = EL.X.
*/
import type { BearingPair } from '../bearing-pair';
import { type BearingSole, SOLE_DEFAULTS, structureSoles } from '../sole-plate';
import { ABUT_PAD_MM, type AbutModel } from './geometry';
import type { AbutMembers } from './members';
/** 교대 솔플레이트 입력 (2026-11-06). Every value optional; absent = the default. */
export interface AbutBearingRule {
/** 받침 본체 높이 h3 (mm). Absent = 받침높이 − T_ref. */
h3Mm?: number;
tRefMm: number;
tMinMm: number;
/** 솔플 여유 L − A = W − B (mm). */
marginMm: number;
/** 받침 평면 A (거더 방향) × B (mm), every bearing. */
aMm: number;
bMm: number;
/** T typed (mm). Absent = 자동. */
tMm?: number;
/** S typed for every bearing (%). Absent = 짝 받침 계산. */
slopePct?: number;
}
export const ABUT_BEARING_RULE_DEFAULT: AbutBearingRule = { tRefMm: SOLE_DEFAULTS.tRefMm, tMinMm: SOLE_DEFAULTS.tMinMm, marginMm: SOLE_DEFAULTS.marginMm, aMm: SOLE_DEFAULTS.aMm, bMm: SOLE_DEFAULTS.bMm };
/** 받침블럭 평면 (mm, 정사각) and 몰탈패드 평면 (mm), spec_abut §4.2. */
export const ABUT_BLOCK_MM = 1200;
export const ABUT_PAD_PLAN_MM = 1000;
/** I01: 블럭 콘크리트 높이가 피복 50 + 철근 지름 13보다 작으면 블럭 철근을 교좌면 아래로 묻는다 (spec_abut §6.6). */
const I01_MIN_MM = 50 + 13;
const CIRCLED = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩'];
/** Keys the 2026-11-06 rule reads; values stored by the C안 (lengthMm, widthMm, kind) are ignored. */
const RULE_KEYS = ['h3Mm', 'tRefMm', 'tMinMm', 'marginMm', 'aMm', 'bMm', 'tMm', 'slopePct'] as const;
export function resolveBearingRule(changes?: Partial<AbutBearingRule> & Record<string, unknown>): AbutBearingRule {
const own = Object.fromEntries(Object.entries(changes ?? {}).filter(([k]) => (RULE_KEYS as readonly string[]).includes(k)));
return { ...ABUT_BEARING_RULE_DEFAULT, ...own };
}
/** Bridge context of the abutment card: the girder pairs and the 교대 종단경사 i (decimal). */
export interface AbutSoleContext {
pairs?: BearingPair[];
slopeI?: number;
/** 엑셀 방위각 per bearing number (deg model 진북), when an applied 계산 방위각 is in the model. */
inputAzimuthDeg?: Record<string, number>;
}
/** Solplate of every bearing of the abutment (one row). */
export function abutSoles(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): BearingSole[] {
const plates = Object.fromEntries(model.bearings.map((b) => [String(b.no), { aMm: rule.aMm, bMm: rule.bMm }]));
return structureSoles(
model.name,
model.bearings.map((b) => ({ no: b.no, row: '1열', azimuthDeg: b.azimuthDeg ?? 0, inputAzimuthDeg: ctx.inputAzimuthDeg?.[String(b.no)] })),
{
h3Mm: rule.h3Mm ?? heightMm - rule.tRefMm, tRefMm: rule.tRefMm, tMinMm: rule.tMinMm, marginMm: rule.marginMm, plates,
tByRow: rule.tMm === undefined ? undefined : { '1열': rule.tMm },
slopePct: rule.slopePct === undefined ? undefined : Object.fromEntries(model.bearings.map((b) => [String(b.no), rule.slopePct!])),
},
ctx.pairs ?? [],
ctx.slopeI === undefined ? undefined : ctx.slopeI * 100,
);
}
export interface AbutElevationRow {
no: number;
/** IP3(① 쪽)에서 잰 위치 (m). */
sFromIp3M: number;
seg: string;
seatEl: number;
h1: number;
h2: number;
/** 패드 상면 = EL.X (m). */
z: number;
h3: number;
h4: number;
E: number;
/** 솔플레이트 상면 EL (m) = 교좌면 + E. */
solplateTopEl: number;
/** 블럭 끝에서 가장 가까운 계단 경계까지 (m). 경계가 없으면 null. */
stepGapM: number | null;
/** 받침 방위각 (deg): CAD (엑셀 값, 동 = 0° 반시계) / 진북 (북에서 시계). 입력이 없으면 null. */
azimuthCadDeg: number | null;
azimuthNorthDeg: number | null;
/** 블럭 평면 회전 (deg), 교대축 직각 기준. */
rotDeg: number;
}
export function abutElevationRows(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): AbutElevationRow[] {
const half = ABUT_BLOCK_MM / 2000;
const soles = new Map(abutSoles(model, rule, heightMm, ctx).map((x) => [x.no, x]));
return model.bearings.map((b) => {
const plan = soles.get(b.no)!;
// 거더 하면 고정: the block concrete loses T − T_ref (2026-11-06, Q-SH7).
const h1 = b.blockHeightMm - ABUT_PAD_MM - plan.dropMm;
const E = h1 + ABUT_PAD_MM + plan.h3 + plan.T;
const gaps = model.stepBounds.map((x) => Math.abs(x - b.s) - half);
return {
no: b.no,
sFromIp3M: model.length - b.s,
seg: CIRCLED[b.no - 1] ?? String(b.no),
seatEl: b.seatEl,
h1,
h2: ABUT_PAD_MM,
z: b.z - plan.dropMm / 1000,
h3: plan.h3,
h4: plan.T,
E,
solplateTopEl: b.seatEl + E / 1000,
stepGapM: gaps.length ? Math.min(...gaps) : null,
azimuthCadDeg: b.azimuthDeg === undefined ? null : (((90 - b.azimuthDeg) % 360) + 360) % 360,
azimuthNorthDeg: b.azimuthDeg === undefined ? null : ((b.azimuthDeg % 360) + 360) % 360,
rotDeg: b.rotDeg,
};
});
}
export interface AbutSolplateRow {
no: number;
/** 기울기 (%). */
S: number;
/** S 근거 (계산 / 입력 / 가정 / 수정됨 / 없음) and the 짝 받침, e.g. "P1-①". */
source: string;
pair?: string;
/** 방위각 검사 (″): input azimuth against the pair direction; absent without a pair. */
azimuthSec?: number;
pairKind?: 'girder' | 'chord';
/** 방위각 입력 (엑셀) and 계산 (짝 받침), deg model 진북; 계산 absent without a pair. */
azimuthInputDeg: number;
azimuthCalcDeg?: number;
L: number;
W: number;
T: number;
T1: number;
T2: number;
/** 검산 문구 "T1 − T2 ≈ L·|S|/100" (W08, 1 mm 이내). */
check: string;
warnings: string[];
}
export function abutSolplateRows(model: AbutModel, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): AbutSolplateRow[] {
return abutSoles(model, rule, heightMm, ctx).map((p) => {
const rhs = (p.L * Math.abs(p.slopePct)) / 100;
return {
no: p.no, S: p.slopePct, source: p.slopeSource, azimuthSec: p.azimuthCheckSec, azimuthInputDeg: p.azimuthInputDeg, azimuthCalcDeg: p.azimuthCalcDeg,
pair: p.pair ? `${p.pair.structure}-${CIRCLED[p.pair.no - 1] ?? p.pair.no}${p.pairKind === 'chord' ? ' (현)' : ''}` : undefined, pairKind: p.pairKind,
L: p.L, W: p.W, T: p.T, T1: p.T1, T2: p.T2,
check: `${p.T1 - p.T2} ≈ ${rhs.toFixed(1)} ${Math.abs(p.T1 - p.T2 - rhs) <= 1 + 1e-9 ? '일치' : '불일치 (W08)'}`,
warnings: p.warnings,
};
});
}
/** Input problems of the 교대 솔플레이트 입력. Empty when it can build a solplate. */
export function abutBearingRuleIssues(rule: AbutBearingRule, heightMm: number): string[] {
const out: string[] = [];
for (const k of ['tRefMm', 'tMinMm', 'aMm', 'bMm'] as const) if (!(Number.isFinite(rule[k]) && rule[k] > 0)) out.push(`${k}는 0보다 커야 합니다.`);
if (!(Number.isFinite(rule.marginMm) && rule.marginMm >= 0)) out.push('솔플 여유는 0 이상이어야 합니다.');
if (rule.tMm !== undefined && !(Number.isFinite(rule.tMm) && rule.tMm > 0)) out.push('T는 0보다 커야 합니다.');
if (rule.slopePct !== undefined && !Number.isFinite(rule.slopePct)) out.push('S는 숫자여야 합니다.');
const h3 = rule.h3Mm ?? heightMm - rule.tRefMm;
if (!(Number.isFinite(h3) && h3 > 0)) out.push(`받침 높이 h3 (${h3})는 0보다 커야 합니다. 받침높이 ${heightMm}와 T_ref를 확인하세요.`);
return out;
}
/** 검사 줄 (시안): W02 블럭 콘크리트, W04 블럭이 교좌면 안, W03 블럭 끝 ~ 계단 경계, I01 블럭 철근. 문제 없으면 "없음". */
export function abutBearingChecks(model: AbutModel, members: AbutMembers, rule: AbutBearingRule, heightMm: number, ctx: AbutSoleContext = {}): string[] {
const rows = abutElevationRows(model, rule, heightMm, ctx);
const minH1 = Math.min(...rows.map((r) => r.h1));
const half = ABUT_BLOCK_MM / 2000;
// Block plan across the wall: centre t = frontSide × lateral, between the 흉벽 front (t 0) and the 벽체 front.
const tCentres = model.bearings.map((b) => model.frontSide * b.lateral);
const backGap = Math.min(...tCentres.map((t) => t - half));
const frontGap = Math.min(...tCentres.map((t) => members.shape.wallFrontM - (t + half)));
const stepGaps = rows.map((r) => r.stepGapM).filter((g): g is number => g !== null);
const minStep = stepGaps.length ? Math.min(...stepGaps) : null;
return [
`블럭 콘크리트 ${minH1} > 0 (W02 ${minH1 > 0 ? '없음' : '있음'})`,
`블럭이 교좌면 안 (흉벽 ${Math.round(backGap * 1000)} · 전면 ${Math.round(frontGap * 1000)}, W04 ${backGap >= 0 && frontGap >= 0 ? '없음' : '있음'})`,
minStep === null ? '블럭 끝 ~ 계단 경계: 경계 없음 (W03 없음)' : `블럭 끝 ~ 계단 경계 최소 ${minStep.toFixed(3)} m (W03 ${minStep >= 0 ? '없음' : '있음'})`,
`${minH1} ≥ ${I01_MIN_MM} → I01 ${minH1 >= I01_MIN_MM ? '없음' : '있음 (블럭 철근을 교좌면 아래로)'}`,
];
}
+297
View File
@@ -0,0 +1,297 @@
/**
* 교대 입력 편집 (2026-11-03, 교대 편집 카드): changes one value of an abutment record with checks. Pure: it returns a new
* record and never changes the old one. Errors are in Korean and name the field.
*/
import type { AbutRecord } from './parse';
import type { WingForm } from './geometry';
import { WING_DIMS_DEFAULT, wingDimsIssues, type WingDims } from './wings';
import { ABUT_SHAPE_DEFAULT, normalizeShape, shapeIssues, type AbutShape } from './shape';
import { resolveSlab, slabIssues, type AbutSlab, type SlabForm } from './slab';
import type { AbutGround } from './ground';
import { footingRebarIssues, resolveFootingRebar, type AbutFootingRebarSettings } from './rebar/footing';
import { resolveWallRebar, wallRebarIssues, type AbutWallRebarSettings } from './rebar/wall';
import { parapetRebarIssues, resolveParapetRebar, type AbutParapetRebarSettings } from './rebar/parapet';
import { resolveWingRebar, wingRebarIssues, type AbutWingRebarSettings } from './rebar/wing';
import { resolveSlabRebar, slabRebarIssues, type AbutSlabRebarSettings } from './rebar/slab';
import { blockRebarIssues, resolveBlockRebar, type AbutBlockSettings } from './rebar/block';
import { abutBearingRuleIssues, resolveBearingRule, type AbutBearingRule } from './bearing-table';
export type AbutEditKey = 'seatEl' | 'foundationBottomEl' | 'groundEl' | 'pileLengthMm' | 'skewDeg' | 'slopeI';
export const ABUT_EDIT_FIELDS: { key: AbutEditKey; label: string; unit: string }[] = [
{ key: 'seatEl', label: '교좌면 EL (구간 ①)', unit: 'm' },
{ key: 'foundationBottomEl', label: '기초 하단 EL', unit: 'm' },
{ key: 'groundEl', label: '지반고 EL', unit: 'm' },
{ key: 'pileLengthMm', label: '말뚝길이', unit: 'mm' },
{ key: 'skewDeg', label: 'skew', unit: '°' },
{ key: 'slopeI', label: '종단경사 i', unit: '' },
];
/** The 저판 thickness (m) below the 기초 하단 is not part of this check; the 교좌면 must sit above the 기초 하단. */
export function applyAbutEdit(rec: AbutRecord, key: AbutEditKey, value: number): AbutRecord {
const label = ABUT_EDIT_FIELDS.find((f) => f.key === key)?.label ?? key;
if (!Number.isFinite(value)) throw new Error(`${label}은(는) 숫자여야 합니다.`);
if (key === 'pileLengthMm' && !(value > 0)) throw new Error(`${label}은(는) 0보다 커야 합니다.`);
if (key === 'skewDeg' && !(value > 0 && value < 180)) throw new Error(`${label}은(는) 0 초과 180 미만이어야 합니다.`);
if (key === 'slopeI' && Math.abs(value) > 0.2) throw new Error(`${label}은(는) ±0.2 이내여야 합니다.`);
const next: AbutRecord = { ...rec, [key]: value };
if (next.seatEl <= next.foundationBottomEl) {
throw new Error('교좌면 EL은 기초 하단 EL보다 높아야 합니다.');
}
return next;
}
/** 날개벽 형식은 측별로 정한다 (2026-11-04: "좌우 같게" 없음). */
export function setWingForm(rec: AbutRecord, side: 'left' | 'right', form: WingForm): AbutRecord {
const current = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
return { ...rec, wings: { ...current, [side]: form } };
}
/**
* 반대쪽에도 적용 (2026-11-04): copies this side's wing dims to the other side once (no lasting link).
* 두께 · 끝 높이 · 헌치는 늘 복사하고, 길이는 두 측의 형식이 같을 때만 복사한다 (단축형 길이 기본 = 뒷굽 끝).
* The other side must have a wing.
*/
export function copyWingDims(rec: AbutRecord, side: 'left' | 'right'): AbutRecord {
const other = side === 'left' ? 'right' : 'left';
const forms = rec.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
if (forms[other] === 'none') throw new Error('반대쪽 날개벽이 없습니다.');
const from = rec.wingDims?.[side] ?? {};
const to: Partial<WingDims> = { ...(rec.wingDims?.[other] ?? {}) };
for (const key of ['thicknessMm', 'endHeightMm', 'haunchMm'] as const) {
if (from[key] === undefined) delete to[key];
else to[key] = from[key];
}
if (forms[side] === forms[other]) {
if (from.lengthMm === undefined) delete to.lengthMm;
else to.lengthMm = from.lengthMm;
}
return { ...rec, wingDims: { ...(rec.wingDims ?? {}), [other]: to } };
}
/** 날개벽 치수 입력 칸 (② 날개벽 카드, 시안 순서). 단축형에는 끝 높이가 없다. */
export const WING_DIM_FIELDS: { key: keyof WingDims; label: string; general: boolean; short: boolean }[] = [
{ key: 'lengthMm', label: '길이 (mm)', general: true, short: true },
{ key: 'thicknessMm', label: '두께 (mm)', general: true, short: true },
{ key: 'endHeightMm', label: '끝 높이 (mm)', general: true, short: false },
{ key: 'haunchMm', label: '헌치 (mm, 300 × 300의 한 변)', general: true, short: true },
];
/** Changes one wing dim of one side. Length, thickness and end height must be > 0, the haunch ≥ 0. */
export function applyWingDim(rec: AbutRecord, side: 'left' | 'right', key: keyof WingDims, value: number): AbutRecord {
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
const issues = wingDimsIssues({ ...WING_DIMS_DEFAULT, [key]: value });
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, wingDims: { ...(rec.wingDims ?? {}), [side]: { ...(rec.wingDims?.[side] ?? {}), [key]: value } } };
}
/** 받침 값 편집 (2026-11-03): z (패드 상면, m) or 교좌면 override (m, undefined = 자동 — 블럭 높이 규칙을 따른다). */
export function applyBearingEdit(rec: AbutRecord, no: number, key: 'z' | 'seatEl', value: number | undefined): AbutRecord {
const target = rec.bearings.find((b) => b.no === no);
if (!target) throw new Error(`받침 ${no}번이 없습니다.`);
if (key === 'z' && (value === undefined || !Number.isFinite(value))) throw new Error('z는 숫자여야 합니다.');
if (key === 'seatEl' && value !== undefined && !Number.isFinite(value)) throw new Error('교좌면 EL은 숫자이거나 비워야 합니다.');
const next = rec.bearings.map((b) => {
if (b.no !== no) return b;
if (key === 'z') return { ...b, z: value as number };
return value === undefined ? { ...b, seatEl: undefined } : { ...b, seatEl: value };
});
const out: AbutRecord = { ...rec, bearings: next };
// The 교좌면 of every bearing must still lie above the 기초 하단 (the 저판 thickness is checked in the geometry step).
const lowest = Math.min(...out.bearings.map((b) => b.seatEl ?? out.seatEl + (b.z - Math.min(...out.bearings.map((x) => x.z)))));
if (lowest <= out.foundationBottomEl) throw new Error(`받침 ${no}번 교좌면 EL이 기초 하단 EL보다 낮거나 같습니다.`);
return out;
}
/** 기본값 복원: the record as imported from the workbook (the wing forms go back to 양측 일반형). */
export function restoreAbutDefault(defaults: AbutRecord): AbutRecord {
return JSON.parse(JSON.stringify(defaults)) as AbutRecord;
}
/** 형상 입력 change (벽체·흉벽, 확대기초·말뚝, mm): refused with a Korean message when the shape would not build. */
/** Several shape values at once (e.g. the 열 위치 "650 · 2000 · 1300 · 650"), checked together. */
export function applyShapeEdits(rec: AbutRecord, values: Partial<AbutShape>): AbutRecord {
for (const v of Object.values(values)) if (!Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const shape = { ...(rec.shape ?? {}), ...values };
const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)];
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, shape };
}
export function applyShapeEdit(rec: AbutRecord, key: keyof AbutShape, value: number): AbutRecord {
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
const shape = { ...(rec.shape ?? {}), [key]: value };
const issues = [...shapeIssues(shape), ...slabFitIssues(rec.slab, shape)];
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, shape };
}
/** 브라켓 높이 (mm) of a shape input, default when absent. */
function bracketHeightMm(shape: Partial<AbutShape> | undefined): number {
return { ...ABUT_SHAPE_DEFAULT, ...normalizeShape(shape) }.bracketHeightMm;
}
/** A lower 브라켓 must still hold the 접속슬래브 under the 흉벽 상단. */
function slabFitIssues(slab: Partial<AbutSlab> | undefined, shape: Partial<AbutShape>): string[] {
const s = resolveSlab(slab);
const h = bracketHeightMm(shape);
return s.thicknessMm > h ? [`브라켓 높이(${h})는 접속슬래브 두께(${s.thicknessMm}) 이상이어야 합니다.`] : [];
}
/** 접속슬래브 형식 (2026-11-04); the form also sets the slope (매립형 하향 4 %, 노출형 종단경사). */
export function setSlabForm(rec: AbutRecord, form: SlabForm): AbutRecord {
const { slope: _old, ...kept } = (rec.slab ?? {}) as Partial<AbutSlab> & { slope?: unknown };
const slab: Partial<AbutSlab> = { ...kept, form };
const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, slab };
}
/** 접속슬래브 입력칸 (② 접속슬래브 카드, 시안 순서). 헌치 길이는 노출형만. */
export const SLAB_EDIT_FIELDS: { key: 'lengthMm' | 'thicknessMm' | 'parapetGapMm' | 'wingGapMm' | 'haunchLengthMm'; label: string; exposedOnly?: boolean; note?: string }[] = [
{ key: 'lengthMm', label: '길이 (mm)' },
{ key: 'thicknessMm', label: '두께 (mm)' },
{ key: 'parapetGapMm', label: '흉벽과 간격 (mm)' },
{ key: 'wingGapMm', label: '날개벽과 간격 (mm)' },
{ key: 'haunchLengthMm', label: '헌치 길이 (mm)', exposedOnly: true, note: '가정 · 삽도 비율' },
];
/**
* 지반·지층 입력 (2026-11-04): merges the given values into the record's 지반; undefined removes a value. Numbers must be finite.
* The values are checked by the card (G01–G14), not refused here, as on the pier card.
*/
export function setAbutGround(rec: AbutRecord, patch: Partial<AbutGround>): AbutRecord {
for (const [key, v] of Object.entries(patch)) {
if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
if (key === 'strataRows' && (v as { bottomEl: number }[]).some((r) => !Number.isFinite(r.bottomEl))) throw new Error('지층 하단 EL은 숫자여야 합니다.');
}
const ground: AbutGround = { ...(rec.ground ?? {}), ...patch };
for (const key of Object.keys(ground) as (keyof AbutGround)[]) if (ground[key] === undefined) delete ground[key];
return { ...rec, ground };
}
/**
* ③ 저판 배근 입력 (2026-11-04): merges the values; undefined removes one (a 철근별 간격 goes back to the 기본간격).
* Refused with a Korean message when the settings would not lay out.
*/
export function applyFootingRebar(rec: AbutRecord, patch: Partial<AbutFootingRebarSettings>): AbutRecord {
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const footing: Partial<AbutFootingRebarSettings> = { ...(rec.rebar?.footing ?? {}), ...patch };
for (const key of Object.keys(footing) as (keyof AbutFootingRebarSettings)[]) if (footing[key] === undefined) delete footing[key];
const issues = footingRebarIssues(resolveFootingRebar(footing));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, rebar: { ...(rec.rebar ?? {}), footing } };
}
/** Changes ③ 벽체 배근 values (only the changed ones are kept; undefined clears one), refused with a Korean message. */
export function applyWallRebar(rec: AbutRecord, patch: Partial<AbutWallRebarSettings>): AbutRecord {
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const wall: Partial<AbutWallRebarSettings> = { ...(rec.rebar?.wall ?? {}), ...patch };
for (const key of Object.keys(wall) as (keyof AbutWallRebarSettings)[]) if (wall[key] === undefined) delete wall[key];
const issues = wallRebarIssues(resolveWallRebar(wall));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, rebar: { ...(rec.rebar ?? {}), wall } };
}
/** Changes ③ 흉벽 · 브라켓 배근 values (only the changed ones are kept; undefined clears one), refused with a Korean message. */
export function applyParapetRebar(rec: AbutRecord, patch: Partial<AbutParapetRebarSettings>): AbutRecord {
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const parapet: Partial<AbutParapetRebarSettings> = { ...(rec.rebar?.parapet ?? {}), ...patch };
for (const key of Object.keys(parapet) as (keyof AbutParapetRebarSettings)[]) if (parapet[key] === undefined) delete parapet[key];
const issues = parapetRebarIssues(resolveParapetRebar(parapet));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, rebar: { ...(rec.rebar ?? {}), parapet } };
}
/** Changes ③ 날개벽 배근 values (좌우 공통, 사용자 결정 2026-11-04; only the changed ones are kept), refused with a Korean message. */
export function applyWingRebar(rec: AbutRecord, patch: Partial<AbutWingRebarSettings>): AbutRecord {
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const wing: Partial<AbutWingRebarSettings> = { ...(rec.rebar?.wing ?? {}), ...patch };
for (const key of Object.keys(wing) as (keyof AbutWingRebarSettings)[]) if (wing[key] === undefined) delete wing[key];
const issues = wingRebarIssues(resolveWingRebar(wing));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, rebar: { ...(rec.rebar ?? {}), wing } };
}
/** Changes ③ 접속슬래브 배근 values (only the changed ones are kept; undefined clears one), refused with a Korean message. */
export function applySlabRebar(rec: AbutRecord, patch: Partial<AbutSlabRebarSettings>): AbutRecord {
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const slab: Partial<AbutSlabRebarSettings> = { ...(rec.rebar?.slab ?? {}), ...patch };
for (const key of Object.keys(slab) as (keyof AbutSlabRebarSettings)[]) if (slab[key] === undefined) delete slab[key];
const issues = slabRebarIssues(resolveSlabRebar(slab));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, rebar: { ...(rec.rebar ?? {}), slab } };
}
/** Changes ③ 받침블럭 배근 values (only the changed ones are kept; undefined clears one), refused with a Korean message. */
export function applyBlockRebar(rec: AbutRecord, patch: Partial<AbutBlockSettings>): AbutRecord {
for (const v of Object.values(patch)) if (typeof v === 'number' && !Number.isFinite(v)) throw new Error('숫자를 입력하세요.');
const block: Partial<AbutBlockSettings> = { ...(rec.rebar?.block ?? {}), ...patch };
for (const key of Object.keys(block) as (keyof AbutBlockSettings)[]) if (block[key] === undefined) delete block[key];
const issues = blockRebarIssues(resolveBlockRebar(block));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, rebar: { ...(rec.rebar ?? {}), block } };
}
/** Changes one 접속슬래브 dim, refused with a Korean message when the slab would not build. */
export function applySlabEdit(rec: AbutRecord, key: (typeof SLAB_EDIT_FIELDS)[number]['key'], value: number): AbutRecord {
if (!Number.isFinite(value)) throw new Error('숫자를 입력하세요.');
const slab = { ...(rec.slab ?? {}), [key]: value };
const issues = slabIssues(resolveSlab(slab), bracketHeightMm(rec.shape));
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, slab };
}
/** 형상 입력 fields on the 교대 카드 (시안 ② 벽체·흉벽, 확대기초·말뚝), in the order of the mockup. */
export const SHAPE_EDIT_FIELDS: { key: keyof AbutShape; label: string; group: 'wall' | 'footing'; note?: string }[] = [
// 벽체 · 흉벽 (시안 입력 격자 순서). 벽체 두께는 자동 = 브라켓 폭 + 흉벽 두께 + 교좌면 폭.
{ key: 'wallFrontMm', label: '교좌면 폭 (mm)', group: 'wall' },
{ key: 'parapetHeightMm', label: '벽체 높이 · 받침 ① 기준 연직 (mm)', group: 'wall', note: '받침 ① 교좌면 ~ 흉벽 상단' },
{ key: 'parapetThicknessMm', label: '흉벽 두께 (mm)', group: 'wall' },
{ key: 'bracketHeightMm', label: '브라켓 높이 (mm)', group: 'wall', note: '= 접속슬래브 카드 흉벽 높이' },
{ key: 'bracketWidthMm', label: '브라켓 폭 (mm)', group: 'wall' },
{ key: 'shrinkJointCount', label: '수축줄눈 개수', group: 'wall' },
{ key: 'parapetLevelLeftMm', label: '흉벽 상단 Level 좌 (mm)', group: 'wall', note: '기준 확인 QA-4' },
{ key: 'parapetLevelRightMm', label: '흉벽 상단 Level 우 (mm)', group: 'wall', note: '기준 확인 QA-4' },
{ key: 'seatStepIgnoreMm', label: '인접 무시 기준 (mm)', group: 'wall' },
// 확대기초 · 말뚝 (분리 카드, 시안 순서). 뒷굽 · 길이 · 선단 EL은 자동, 열 위치와 열별 본수는 카드에서 따로 입력한다.
{ key: 'footingWidthMm', label: '폭 (mm)', group: 'footing' },
{ key: 'footingThicknessMm', label: '두께 (mm)', group: 'footing' },
{ key: 'footingToeMm', label: '앞굽 (mm)', group: 'footing' },
{ key: 'blindingMarginMm', label: '버림 여유 한쪽 (mm)', group: 'footing' },
{ key: 'blindingThicknessMm', label: '버림 두께 (mm)', group: 'footing' },
{ key: 'pileDiameterMm', label: '말뚝 지름 (mm)', group: 'footing', note: 'QA-5' },
{ key: 'pileEdgeMm', label: '연단 · 길이 방향 (mm)', group: 'footing' },
{ key: 'pileSpacingMm', label: '본수 규칙 간격 (mm)', group: 'footing' },
];
/** 받침 규칙 입력칸 (② 교량받침 카드): 교각 솔플레이트 규칙과 같은 칸. 받침높이 H는 교대 받침 시트 값으로 고정 (2026-11-03). */
/** 교대 솔플레이트 입력 칸 (2026-11-06). Optional ones (h3, T, S) go back to the automatic value when cleared. */
export const BEARING_RULE_FIELDS: { key: keyof AbutBearingRule; label: string; optional?: boolean }[] = [
{ key: 'h3Mm', label: '받침 높이 h3 (mm, 비우면 받침높이 − T_ref)', optional: true },
{ key: 'tRefMm', label: 'T_ref · 엑셀 z 산정 때 두께 (mm)' },
{ key: 'tMinMm', label: '솔플 최소두께 T_min (mm)' },
{ key: 'marginMm', label: '솔플 여유 L − A = W − B (mm)' },
{ key: 'aMm', label: '받침 A · 거더 방향 (mm)' },
{ key: 'bMm', label: '받침 B (mm)' },
{ key: 'tMm', label: '솔플레이트 T (mm, 비우면 자동)', optional: true },
{ key: 'slopePct', label: 'S 직접 수정 (%, 비우면 짝 받침 계산)', optional: true },
];
/**
* Changes one 솔플레이트 입력 value (2026-11-06); `undefined` clears an optional one back to automatic. The result must
* still build a solplate (positive sizes, h3 > 0 with the 교대 받침높이).
*/
export function applyBearingRule(rec: AbutRecord, key: keyof AbutBearingRule, value: number | undefined): AbutRecord {
const bearingRule: Record<string, unknown> = { ...(rec.bearingRule ?? {}) };
if (value === undefined) delete bearingRule[key];
else {
const n = Number(value);
if (!Number.isFinite(n)) throw new Error('솔플레이트 입력 값은 숫자여야 합니다.');
bearingRule[key] = n;
}
const issues = abutBearingRuleIssues(resolveBearingRule(bearingRule), rec.bearingHeightMm);
if (issues.length > 0) throw new Error(issues[0]);
return { ...rec, bearingRule };
}
+211
View File
@@ -0,0 +1,211 @@
/**
* 교대 형상 · AA1 (2026-11-03, spec_abut §1·§4.1–4.2): 교대축, 전면 쪽, 교좌면 위의 받침, 교좌면 EL과 블럭 높이를 계산한다.
* 순수 함수. 좌표는 (E, N) m 단위, EL은 m 단위, 길이는 별도 표기가 없으면 mm 단위다.
*
* - 축: IP1 → IP3 선(교대중심선), 길이 L. u = IP1 → IP3 단위벡터, nLeft = u를 반시계 90° 돌린 벡터.
* - 받침 s: IP1에서 u 방향으로 잰 거리(m). 축에서 벗어난 거리(lateral): nLeft 방향 거리(m).
* - z = 패드 상면(EL.X), 교각과 같다(2026-11-03 결정 1). 교좌면에서 패드 상면까지 블럭 높이는 125 mm
* (콘크리트 75 + 패드 50). 교좌면 EL은 콘크리트 하면이다.
* - 교좌면: 교대 전체에 교좌면 EL 하나를 입력받는다(첫 받침 ①). 블럭 높이 h = min(z) − 교좌면으로 모든 받침이 같고,
* 받침 i의 교좌면 = z_i − h. 받침이 자기 교좌면 EL(seatEl)을 직접 주면 그 값을 쓰고, 블럭 높이는 z_i − seatEl이 된다
* (2026-11-03 결정 3).
* - 전면: 받침이 있는 쪽. A1은 전면이 1 → n 방향의 왼쪽, 흉벽은 오른쪽에 있다. A2는 반대다(spec_abut §1).
* 맞지 않으면 AW03 경고 대상이다.
*/
export type AbutType = 'A1' | 'A2';
export interface AbutPoint {
e: number;
n: number;
}
export interface AbutBearing {
no: number;
/** 위치 (E, N), m. */
pos: AbutPoint;
/** 패드 상면 EL (z), m. */
z: number;
/** 이 받침의 교좌면 EL (m). 없으면 z에서 교대 블럭 높이를 뺀 값을 쓴다. */
seatEl?: number;
/** 받침 방위각 (deg, model-xy: 북에서 시계방향, 교각과 같은 변환 swapXYAzimuth). 없으면 블럭은 교대축에 맞춘다. */
azimuthDeg?: number;
}
/** 날개벽 형식, 측별(spec_abut §4.7): 일반형 / 단축형 / 없음. */
export type WingForm = 'general' | 'short' | 'none';
export interface AbutInput {
name: string;
type: AbutType;
/** 첫 받침(구간 ①)의 교좌면 EL (m). 블럭 높이 h = min(z) − seatEl. */
seatEl: number;
ip1: AbutPoint;
ip2: AbutPoint;
ip3: AbutPoint;
bearings: AbutBearing[];
/** skew (deg): 받침축을 시계방향으로 돌린 선이 날개벽 선이다(spec_abut §1). 없으면 날개벽 검사를 하지 않는다. */
skewDeg?: number;
/** 종단경사 i (소수, 교량진행방향 기준, (−) = 낮아짐). 날개벽 경사는 A1이 −i, A2가 +i다. */
slopeI?: number;
/** AW01용 거더 방향 CAD 각 (deg). 없으면 AW01을 검사하지 않는다. */
girderAzimuthDeg?: number;
/** 측별 날개벽 형식: 'left' = 받침 ① 쪽 끝, 'right' = 받침 n 쪽 끝(spec §4.7). 기본은 양측 일반형. */
wings?: { left: WingForm; right: WingForm };
/** 측별 날개벽 두께 (mm). 없으면 450. 접속슬래브 폭에 쓴다. */
wingThicknessMm?: { left?: number; right?: number };
/** 접속슬래브와 날개벽 안쪽면 사이 간격 (mm, ② 접속슬래브 카드). 없으면 20. */
slabWingGapMm?: number;
/** 기초 하단 EL (m), 말뚝길이 (mm), 기초 형식 (AA3, 저판·말뚝). 없으면 저판·말뚝을 만들지 않는다. */
foundationBottomEl?: number;
pileLengthMm?: number;
foundationType?: string;
}
export interface AbutWingModel {
/** 날개벽 선, CAD 각 (deg), 배면 쪽(예제: A1 ≈ 255.32, A2 ≈ 75.32). */
lineDeg: number;
/** 날개벽 선과 거더 방향의 사잇각, 0–90°로 접음(AW01 허용 1°). */
girderDiffDeg: number;
aw01Ok: boolean;
/** 날개벽 경사 (%), (+) = 벽체에서 멀어질수록 높아짐. */
slopePct: number;
/** 날개벽 사이 접속슬래브 폭 (mm) = L − Σ(두께 / |sin Δ| + 날개벽과 간격), 있는 날개벽마다. */
slabWidthMm: number;
wings: { left: WingForm; right: WingForm };
}
export interface AbutBearingModel {
no: number;
/** IP1에서 u 방향 거리 (m). */
s: number;
/** nLeft 방향으로 잰 축에서의 부호 있는 거리 (m). */
lateral: number;
z: number;
seatEl: number;
/** 교좌면에서 패드 상면까지 블럭 높이 (mm), 패드 포함. */
blockHeightMm: number;
/** 받침 방위각 (deg, model-xy). 입력이 없으면 undefined. */
azimuthDeg?: number;
/** 블럭 평면 회전 (deg, −90 … 90): 받침 방위각과 교대축 직각(전면) 방향 사이의 각. 장면에서는 rotateY(이 값). 입력이 없으면 0. */
rotDeg: number;
}
export interface AbutModel {
name: string;
type: AbutType;
/** IP1 (축의 시작점, s = 0). */
ip1: AbutPoint;
/** 축 길이 IP1 → IP3 (m). */
length: number;
u: AbutPoint;
nLeft: AbutPoint;
/** 받침이 축의 nLeft 쪽이면 +1, 반대쪽이면 −1. */
frontSide: 1 | -1;
/** 받침이 1 → n 방향의 왼쪽이면 true (A1 배치). */
frontLeftOfBearingDir: boolean;
/** 입력 순서의 받침. */
bearings: AbutBearingModel[];
/** 축 위 계단 경계(IP1에서 m), 이웃 받침 사이의 중간. */
stepBounds: number[];
/** AW03: 배치가 형식과 맞으면 true. */
layoutMatchesType: boolean;
/** 날개벽 선, 경사, AW01, 접속슬래브 폭. 입력에 skew가 없으면 없다. */
wing?: AbutWingModel;
}
const WING_THICKNESS_MM = 450;
const SLAB_CLEARANCE_MM = 20;
const AW01_TOLERANCE_DEG = 1;
/** 공통 규칙의 패드 두께 (mm). 블럭 높이에 포함된다(패드 포함). */
export const ABUT_PAD_MM = 50;
const sub = (a: AbutPoint, b: AbutPoint): AbutPoint => ({ e: a.e - b.e, n: a.n - b.n });
/**
* 블럭 평면 회전 (deg): the bearing azimuth direction d = (sin az, cos az) seen in the axis frame (s along u, t along the front),
* folded to −90 … 90 because the block is symmetric. 0 when the azimuth is missing.
*/
export function bearingRotDeg(azimuthDeg: number | undefined, u: AbutPoint, front: AbutPoint): number {
if (azimuthDeg === undefined || !Number.isFinite(azimuthDeg)) return 0;
const r = (azimuthDeg * Math.PI) / 180;
const d = { e: Math.sin(r), n: Math.cos(r) };
let phi = (Math.atan2(d.e * u.e + d.n * u.n, d.e * front.e + d.n * front.n) * 180) / Math.PI;
if (phi > 90) phi -= 180;
if (phi < -90) phi += 180;
return phi;
}
const dot = (a: AbutPoint, b: AbutPoint) => a.e * b.e + a.n * b.n;
const cross = (a: AbutPoint, b: AbutPoint) => a.e * b.n - a.n * b.e;
export function buildAbut(input: AbutInput): AbutModel {
if (input.bearings.length < 2) throw new Error(`교대 ${input.name}: 받침이 2개 이상 필요합니다.`);
const axisVec = sub(input.ip3, input.ip1);
const length = Math.hypot(axisVec.e, axisVec.n);
if (!(length > 0)) throw new Error(`교대 ${input.name}: IP1과 IP3이 같은 점입니다.`);
const u = { e: axisVec.e / length, n: axisVec.n / length };
const nLeft = { e: -u.n, n: u.e };
const lateral = input.bearings.map((b) => dot(sub(b.pos, input.ip1), nLeft));
const meanLateral = lateral.reduce((a, x) => a + x, 0) / lateral.length;
const frontSide: 1 | -1 = meanLateral >= 0 ? 1 : -1;
// 1 → n 방향: 첫 받침(①)에서 마지막 받침까지. 전면 = 받침 쪽. 형식과 맞는지 확인한다.
const first = input.bearings[0];
const last = input.bearings[input.bearings.length - 1];
const dir = sub(last.pos, first.pos);
const frontVec = { e: nLeft.e * frontSide, n: nLeft.n * frontSide };
const frontLeftOfBearingDir = cross(dir, frontVec) > 0;
const layoutMatchesType = input.type === 'A1' ? frontLeftOfBearingDir : !frontLeftOfBearingDir;
const minZ = Math.min(...input.bearings.map((b) => b.z));
const bearings: AbutBearingModel[] = input.bearings.map((b, i) => {
const s = dot(sub(b.pos, input.ip1), u);
const seatEl = b.seatEl ?? input.seatEl + (b.z - minZ);
const blockHeightMm = Math.round((b.z - seatEl) * 1000);
return { no: b.no, s, lateral: lateral[i], z: b.z, seatEl, blockHeightMm, azimuthDeg: b.azimuthDeg, rotDeg: bearingRotDeg(b.azimuthDeg, u, frontVec) };
});
const sorted = [...bearings].sort((a, b) => a.s - b.s);
const stepBounds = sorted.slice(1).map((b, i) => (sorted[i].s + b.s) / 2);
const wing = input.skewDeg === undefined ? undefined : buildWing(input, u, nLeft, frontSide, dir, length);
return { name: input.name, type: input.type, ip1: input.ip1, length, u, nLeft, frontSide, frontLeftOfBearingDir, bearings, stepBounds, layoutMatchesType, wing };
}
const deg = (rad: number) => (rad * 180) / Math.PI;
const norm360 = (x: number) => ((x % 360) + 360) % 360;
/** 두 선 방향의 차이(deg), 0–90°로 접는다. 선에는 방향이 없다. */
const lineDiff = (a: number, b: number) => {
const d = norm360(a - b) % 180;
return Math.min(d, 180 - d);
};
function buildWing(input: AbutInput, u: AbutPoint, nLeft: AbutPoint, frontSide: 1 | -1, dir: AbutPoint, length: number): AbutWingModel {
const skew = input.skewDeg!;
const bearingCad = norm360(deg(Math.atan2(dir.n, dir.e)));
// skew만큼 시계방향으로 돌린 선이 날개벽 선이다. 배면 쪽(받침 반대쪽)이 방향을 정한다.
const line = norm360(bearingCad - skew);
const back = { e: -nLeft.e * frontSide, n: -nLeft.n * frontSide };
const radLine = (line * Math.PI) / 180;
const pick = Math.cos(radLine) * back.e + Math.sin(radLine) * back.n > 0 ? line : norm360(line + 180);
const axisCad = norm360(deg(Math.atan2(u.n, u.e)));
const sinDelta = Math.abs(Math.sin(((pick - axisCad) * Math.PI) / 180));
const wings = input.wings ?? { left: 'general' as WingForm, right: 'general' as WingForm };
// 날개벽 두께는 측별 입력 (2026-11-04, 없으면 450): 축 방향 폭 = 두께 / |sin Δ|.
const thick = { left: input.wingThicknessMm?.left ?? WING_THICKNESS_MM, right: input.wingThicknessMm?.right ?? WING_THICKNESS_MM };
const gap = input.slabWingGapMm ?? SLAB_CLEARANCE_MM;
const present = (f: WingForm, side: 'left' | 'right') => (f === 'none' ? 0 : thick[side] / sinDelta + gap);
const slabWidthMm = Math.round(length * 1000 - present(wings.left, 'left') - present(wings.right, 'right'));
const girderDiffDeg = input.girderAzimuthDeg === undefined ? NaN : lineDiff(pick, input.girderAzimuthDeg);
const slopeI = input.slopeI ?? 0;
return {
lineDeg: pick,
girderDiffDeg,
aw01Ok: Number.isNaN(girderDiffDeg) ? true : girderDiffDeg <= AW01_TOLERANCE_DEG,
slopePct: (input.type === 'A1' ? -slopeI : slopeI) * 100,
slabWidthMm,
wings,
};
}
+67
View File
@@ -0,0 +1,67 @@
/**
* 교대 지반·지층 (2026-11-04, 시안 ② 지반·지층 카드 = 교각과 같은 카드). 순수 함수.
*
* - 입력은 교각과 같은 이름이다: 위치(육상 · 하상 · 해상), 지하수위, 설계홍수위 · 세굴, 고조위 · 저조위, 지층 행.
* 지반고는 엑셀 입력(①에서 수정)이다. 표시만 하고 형상 계산에는 쓰지 않는다.
* - 검사는 교각과 같다(G01–G14, 말뚝 근입 G04). 지반고가 저판 하면보다 낮으면 흙덮개가 없다는 정보 AI01을 더한다.
*/
import { SUPPORT_STRATUM, validateGround, validatePileEmbedment, validateStrataNValues, type GroundCheckInput } from '../ground';
import type { Issue, PierInput } from '../types';
import type { AbutMembers } from './members';
import type { AbutRecord } from './parse';
export type AbutGround = Pick<PierInput, 'groundPosition' | 'groundWaterOn' | 'groundWaterEl' | 'floodLevelEl' | 'scourDepthM' | 'highWaterEl' | 'lowWaterEl' | 'strataRows'>;
/** 말뚝 두께 (mm): spec_abut 말뚝 Φ600 × 12T. Used only by the 해상 부식 여유 check. */
const ABUT_PILE_THICKNESS_MM = 12;
/** The check input of an abutment: its 지반 values, 지반고 and 저판 하면 (기초 하단 when the 저판 is not built). */
export function abutGroundCheckInput(rec: AbutRecord, members?: AbutMembers): GroundCheckInput {
return {
name: rec.name,
groundEl: rec.groundEl,
footingBottomEl: members?.footingBottomEl ?? rec.foundationBottomEl,
pileThicknessMm: ABUT_PILE_THICKNESS_MM,
...(rec.ground ?? {}),
};
}
/** 연암 상면 EL: the bottom of the row above the 연암 row (지반고 for the first row); undefined without a 연암 row. */
export function supportTopEl(groundEl: number, rows: { name: string; bottomEl: number }[] | undefined): number | undefined {
const list = rows ?? [];
const k = list.findIndex((r) => r.name === SUPPORT_STRATUM);
if (k < 0) return undefined;
return k === 0 ? groundEl : list[k - 1].bottomEl;
}
/** Issues of the 지반·지층 card: the pier checks plus AI01 (지반고 below the 저판 하면). */
export function abutGroundIssues(rec: AbutRecord, members?: AbutMembers): Issue[] {
const input = abutGroundCheckInput(rec, members);
const issues: Issue[] = [];
validateGround(input, issues);
validateStrataNValues(input, issues);
if (members && members.pileBottomEl !== undefined && members.piles.length > 0) {
validatePileEmbedment(input, { piles: [{ bottom: members.pileBottomEl, diameter: members.shape.pileDiameterM }] }, issues);
}
if (rec.groundEl < input.footingBottomEl) {
issues.push({
code: 'AI01',
level: 'I',
pier: rec.name,
message: `지반고 ${rec.groundEl.toFixed(3)}이 저판 하면 ${input.footingBottomEl.toFixed(3)}보다 ${(input.footingBottomEl - rec.groundEl).toFixed(3)} 낮아 흙덮개가 없습니다.`,
});
}
return issues;
}
/** True when any 지반 value differs from the default (육상, 지하수위 없음, 지층 행 없음). */
export function abutGroundChanged(ground: AbutGround | undefined): boolean {
if (!ground) return false;
return Object.entries(ground).some(([key, v]) => {
if (v === undefined) return false;
if (key === 'groundPosition') return v !== '육상';
if (key === 'groundWaterOn') return v === true;
if (key === 'strataRows') return (v as unknown[]).length > 0;
return true;
});
}
+35
View File
@@ -0,0 +1,35 @@
import type { Issue } from '../types';
export interface AbutImportIssueGroup {
name?: string;
primary: Issue[];
related: Issue[];
}
/** Keep root errors visible while retaining every dependent row in a disclosure. */
export function groupAbutImportIssues(issues: readonly Issue[]): AbutImportIssueGroup[] {
const groups: { name?: string; issues: Issue[] }[] = [];
const byName = new Map<string, { name: string; issues: Issue[] }>();
for (const issue of issues) {
if (issue.pier === undefined) {
groups.push({ issues: [issue] });
continue;
}
let group = byName.get(issue.pier);
if (!group) {
group = { name: issue.pier, issues: [] };
byName.set(issue.pier, group);
groups.push(group);
}
group.issues.push(issue);
}
return groups.map(({ name, issues: items }) => {
const primary = items.filter((issue) => issue.code !== 'AP04');
const related = items.filter((issue) => issue.code === 'AP04');
if (!primary.length && related.length) primary.push({
...related[0],
message: `교대 ${name ?? ''}: 교대 정보 행이 없어 IP·받침 ${related.length}개 행을 연결하지 못했습니다. 전체 교대명을 확인하세요.`,
});
return { name, primary, related };
});
}
Loaded 100 of 2810 files, more files were not shown because too many files have changed in this diff. Show more