From 9e4b4abdfb92e527031d856d719fd798c5098dba Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:33:24 +0900 Subject: [PATCH 01/37] =?UTF-8?q?feat(design):=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=ED=98=95=20=ED=9A=A1=EB=8B=A8=EC=88=98=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EA=B0=9C=EA=B1=B0(=EA=B2=89=EB=8F=84=EB=9E=91)=20=3D=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=B8=A1=EC=A0=90=20+=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EB=8B=A8=EC=9C=84=20=EC=9E=87=EA=B8=B0=EB=A7=8C(?= =?UTF-8?q?=ED=91=9C=2005=20=C2=B7=2052-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 개거 옛 옵션(규격 · 연장)을 정의에서 뺌 — 저장된 값은 지난 정의 값으로 남음 · 정의 판 7 · 이력 판_6 - 두 종류 모두 컨테이너 0 · 옵션 0 · 기준 측점 바로 아래 구조물 조합 잇기 1(52-1 공용 틀) - 시험: 레지스트리 정책에 옵션 없음 한 줄 · ui_checks/52-5_횡단수로_개거 (B05 · B06 같음) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- resources/master_template/구조물정의.json | 27 +- .../master_template/구조물정의_이력/판_6.json | 3359 +++++++++++++++++ .../test_b05_structures_registry_policy.py | 1 + .../tester/ui_checks/52-5_횡단수로_개거.js | 158 + 4 files changed, 3520 insertions(+), 25 deletions(-) create mode 100644 resources/master_template/구조물정의_이력/판_6.json create mode 100644 resources/tester/ui_checks/52-5_횡단수로_개거.js diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 5b2e2bf07..6694fae47 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 6, + "판": 7, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -676,30 +676,7 @@ "cross_section", "quantity" ], - "options": [ - { - "key": "ditch_spec", - "label": "규격", - "input": "select", - "choices": [ - "콘크리트 개거 150×200", - "L형수로 H=0.2" - ], - "default": null, - "required": false, - "phase": "detail", - "empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎" - }, - { - "key": "length_m", - "label": "연장", - "input": "number", - "unit": "m", - "default": null, - "required": false, - "empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)" - } - ] + "options": [] }, { "type_id": "ditch_side", diff --git a/resources/master_template/구조물정의_이력/판_6.json b/resources/master_template/구조물정의_이력/판_6.json new file mode 100644 index 000000000..5b2e2bf07 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_6.json @@ -0,0 +1,3359 @@ +{ + "판": 6, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": null, + "required": false + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "ditch_spec", + "label": "규격", + "input": "select", + "choices": [ + "콘크리트 개거 150×200", + "L형수로 H=0.2" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎" + }, + { + "key": "length_m", + "label": "연장", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)" + } + ] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "콘크리트", + "돌(찰)", + "돌(메)", + "블록쌓기", + "돌망태", + "흙포대", + "통나무쌓기", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(구 사토장)", + "placement": "interval", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "쌓는 쪽", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "required": false, + "phase": "b05", + "default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈" + }, + { + "key": "fill_slope_ratio", + "label": "비탈 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리" + }, + { + "key": "extra_distance_m", + "label": "추가 운반거리", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "detail", + "default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.structure": "구조", + "soil_add.structure=기슭막이": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ] + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ] + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": { + "pipe": { + "pipe_kind": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index 2f2c006c7..d17cd6d55 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -366,6 +366,7 @@ def test_a6_a7_types_exist_for_manual_placement(): item = by_id[type_id] assert item.group == "A" and item.managed_by is None assert item.placement == "point" + assert not item.options, f"{type_id} 옵션 없음 — 기준 측점 + 종류 단위 잇기만(PLAN 52-5)" def test_ford_bridge_shares_pipe_kind_and_diameter(): diff --git a/resources/tester/ui_checks/52-5_횡단수로_개거.js b/resources/tester/ui_checks/52-5_횡단수로_개거.js new file mode 100644 index 000000000..6745c6ed2 --- /dev/null +++ b/resources/tester/ui_checks/52-5_횡단수로_개거.js @@ -0,0 +1,158 @@ +/* 52-5 — B05 · B06 좌측 「구조물 배치」 노출형 횡단수로 · 개거(겉도랑) = 사용자 구조물 표 05 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) · 폼 비어 있음 · 로그인 상태 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m525 = "단계";` 를 붙여 + * orca eval --expression "window.__m525='edit'; $(cat 이 파일)" → 두 종류 모양 · 추가 · 잇기 · [저장] + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m525='verify'; $(cat 이 파일)" → 저장 값 그대로 · 다시 고름 + * (B06 으로 옮겨 — 같은 모양) + * orca eval --expression "window.__m525='verify'; $(cat 이 파일)" → B06 에서도 같은 값 · 같은 모양 + * orca eval --expression "window.__m525='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장] + * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움) + * 기대 수치: + * shape(종류마다) — optionInputs 0 · containers 0 · summaryRows 1 · summaryVisible true · summaryRightAfterPosition true · + * anchor true · stale false + * edit — added 둘 · 잇기 이름 값 있음 · 저장 뒤 목록 2 늘어남 + * verify — 같은 모양 · 잇기 이름 그대로 · 옵션 값 없음 + */ +(async () => { + const MODE = window.__m525 || "edit"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const vis = (n) => !!n && n.offsetParent !== null; + const sec = () => document.querySelector(".b05-structure-section"); + const body = () => sec().querySelector(".b05-route__panel-body"); + const selects = () => sec().querySelectorAll(".b05-structure__grid select"); + const fire = (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const NAMES = { cross_drain_exposed: "노출형 횡단수로", open_ditch: "개거(겉도랑)" }; + const AT = { cross_drain_exposed: [2, 7], open_ditch: [6, 3] }; + + const shape = (typeId) => { + const kids = [...body().children]; + const position = kids.find((c) => c.classList.contains("b05-structure__position-row")); + const summary = kids.find((c) => c.classList.contains("b05-structure__summary")); + const optionRow = kids.filter((c) => c.classList.contains("b05-structure__grid"))[1]; + const after = kids.slice(kids.indexOf(position) + 1).find((c) => vis(c)); + const anchorWrap = position.querySelectorAll(":scope > label")[2]; + return { + typeId, + optionInputs: vis(optionRow) ? optionRow.querySelectorAll("input, select").length : 0, + containers: [...sec().querySelectorAll("[data-spec-container]")].filter(vis).length, + summaryRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length, + summaryVisible: vis(summary), + summaryRightAfterPosition: after === summary, + anchor: vis(anchorWrap), + stale: vis(sec().querySelector(".b05-structure__stale")), + linked: summary.querySelector(".b05-structure__summary-name")?.textContent ?? "", + }; + }; + const pickType = async (typeId) => { + const [group, type] = selects(); + fire(group, "A"); + await w(300); + fire(type, typeId); + await w(500); + }; + const linkFirstLeaf = async () => { + sec().querySelector("[data-b05-summary=pick]").click(); + await until(() => document.querySelector(".ui-item-picker")); + await w(600); + const modal = document.querySelector(".ui-item-picker"); + // 끝 항목 = ▸ · ▾ 로 안 시작하고 모달 조작 단추가 아닌 첫 단추 + const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"]; + const leaf = [...modal.querySelectorAll("button")].find((b) => { + const t = b.textContent.trim(); + return t && !/^[▸▾]/.test(t) && !ctl.includes(t); + }); + leaf?.click(); + await w(300); + [...modal.querySelectorAll("button")].find((b) => b.textContent.trim() === "적용")?.click(); + await w(500); + return !!leaf; + }; + const clickBtn = async (text) => { + [...document.querySelectorAll("button")] + .find((b) => b.textContent.trim() === text && vis(b)) + ?.click(); + await w(1500); + }; + const out = { mode: MODE, items: [] }; + + if (MODE === "edit") { + await until(() => listItems().length > 0); + out.before = listItems().length; + for (const typeId of Object.keys(NAMES)) { + await pickType(typeId); + const row = { ...shape(typeId), phase: "edit" }; + const [no, dist] = sec() + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input"); + fire(no, String(AT[typeId][0])); + fire(dist, String(AT[typeId][1])); + await w(200); + row.picked = await linkFirstLeaf(); + row.linked = shape(typeId).linked; + sec().querySelector(".is-primary").click(); // 추가 + await w(800); + out.items.push(row); + } + out.after = listItems().length; + await clickBtn("저장"); + out.listed = listItems() + .map((l) => l.textContent.trim()) + .filter((t) => /횡단수로|개거/.test(t)); + } else if (MODE === "verify") { + await until(() => listItems().length > 0); + for (const typeId of Object.keys(NAMES)) { + const li = listItems().find((l) => l.textContent.includes(NAMES[typeId])); + if (!li) { + out.items.push({ typeId, missing: true }); + continue; + } + (li.querySelector("button") ?? li).click(); + await w(800); + // B06 은 다른 항목이 골라져 있으면 첫 눌림이 풀기만 함 — 안 불러와졌으면 한 번 더 + if (selects()[1].value !== typeId) { + (li.querySelector("button") ?? li).click(); + await w(800); + } + out.items.push({ ...shape(typeId), phase: "verify", title: li.title || "" }); + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + await w(300); + } + } else if (MODE === "restore") { + await until(() => listItems().length > 0); + for (const typeId of Object.keys(NAMES)) { + const li = listItems().find((l) => l.textContent.includes(NAMES[typeId])); + if (!li) continue; + (li.querySelector("button") ?? li).click(); + await w(600); + await clickBtn("삭제"); + } + await clickBtn("저장"); + out.left = listItems().length; + } + const rows = out.items.filter((r) => !r.missing); + out.shapeOk = + rows.length === 2 && + rows.every( + (r) => + r.optionInputs === 0 && + r.containers === 0 && + r.summaryRows === 1 && + r.summaryVisible && + r.summaryRightAfterPosition && + r.anchor && + !r.stale, + ); + return JSON.stringify(out); +})(); From 4c0174bf27924ded0de77096455bfce5792ed844 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:38:40 +0900 Subject: [PATCH 02/37] =?UTF-8?q?feat(structure):=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=B0=B0=EC=88=98=20=EB=A7=B9=EC=95=94=EA=B1=B0=20=EC=83=88=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98(=ED=91=9C=2006=20=C2=B7=2052-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정의 판 8 — 새 종류 cross_underdrain(A 횡단배수 · 점형 · 이름 맹암거 · 약자 횡맹 · 색 따로) · 종단배수 underdrain 그대로 - 컨테이너 = 정의 그룹 gcrossud 「맹암거」 — 암거 높이(m) · 암거 경사(%) 손 입력 · 기본값 없음 · 그룹 잇기로 구조물 조합 항목 - 시험 test_52_6_cross_underdrain · 종류 수 기대값(B05 선택지 24 · 정의 38) · ORCA 시나리오 52-6_횡단맹암거 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- resources/master_template/구조물정의.json | 49 +- .../master_template/구조물정의_이력/판_7.json | 3336 +++++++++++++++++ .../tester/test_52_6_cross_underdrain.py | 91 + .../tester/test_b05_structures_define.py | 2 +- .../test_b05_structures_registry_policy.py | 4 +- resources/tester/ui_checks/52-6_횡단맹암거.js | 216 ++ 6 files changed, 3694 insertions(+), 4 deletions(-) create mode 100644 resources/master_template/구조물정의_이력/판_7.json create mode 100644 resources/tester/test_52_6_cross_underdrain.py create mode 100644 resources/tester/ui_checks/52-6_횡단맹암거.js diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 6694fae47..a62bd0a6e 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 7, + "판": 8, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -678,6 +678,42 @@ ], "options": [] }, + { + "type_id": "cross_underdrain", + "group": "A", + "name": "맹암거", + "placement": "point", + "style": { + "color": "#3aa0a8", + "abbr": "횡맹" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "culvert_height_m", + "label": "암거 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + }, + { + "key": "culvert_slope_pct", + "label": "암거 경사", + "input": "number", + "unit": "%", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + } + ] + }, { "type_id": "ditch_side", "enabled": false, @@ -3329,6 +3365,17 @@ "옵션": [], "형상": {} } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } ] }, "배치": {}, diff --git a/resources/master_template/구조물정의_이력/판_7.json b/resources/master_template/구조물정의_이력/판_7.json new file mode 100644 index 000000000..6694fae47 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_7.json @@ -0,0 +1,3336 @@ +{ + "판": 7, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": null, + "required": false + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "콘크리트", + "돌(찰)", + "돌(메)", + "블록쌓기", + "돌망태", + "흙포대", + "통나무쌓기", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(구 사토장)", + "placement": "interval", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "쌓는 쪽", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "required": false, + "phase": "b05", + "default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈" + }, + { + "key": "fill_slope_ratio", + "label": "비탈 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리" + }, + { + "key": "extra_distance_m", + "label": "추가 운반거리", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "detail", + "default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.structure": "구조", + "soil_add.structure=기슭막이": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ] + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ] + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": { + "pipe": { + "pipe_kind": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/test_52_6_cross_underdrain.py b/resources/tester/test_52_6_cross_underdrain.py new file mode 100644 index 000000000..b8146e009 --- /dev/null +++ b/resources/tester/test_52_6_cross_underdrain.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +"""52-6 횡단배수 › 맹암거(표 06 · 새 종류) — 정의 · 컨테이너 · 서버 저장 왕복. + +새 키 `cross_underdrain`(횡단배수 · 점형) — 종단배수 맹암거(`underdrain`)와 따로. +컨테이너 = 정의 그룹 「맹암거」(필수 · 늘 한 벌) — 암거 높이(m) · 암거 경사(%) 손 입력(비워 둘 수 있음) · +그룹 잇기(`group_items`)로 구조물 조합 항목. 저장은 임시 storage 에서만. +""" + +import pytest + +from B05_Profile.B05_Profile_Structures_Schema import ( + StructureInstance, + is_station_planting_type, + load_definition, + structure_type_map, +) + +KEY = "cross_underdrain" +GROUP = "gcrossud" + + +def test_새_종류는_횡단배수_점형_종단배수_맹암거와_따로(): + types = structure_type_map() + new, old = types[KEY], types["underdrain"] + assert (new.group, new.placement, new.name, new.enabled) == ("A", "point", "맹암거", True) + assert (old.group, old.placement) == ("B", "interval") + assert new.style["abbr"] != old.style["abbr"] and new.style["color"] != old.style["color"] + assert is_station_planting_type(new) # 횡단배수 = 그 측점에 횡단도 + + +def test_컨테이너_맹암거_높이_경사_손_입력(): + opts = {o.key: o for o in structure_type_map()[KEY].options} + assert list(opts) == ["culvert_height_m", "culvert_slope_pct"] + for key, label, unit in ( + ("culvert_height_m", "암거 높이", "m"), + ("culvert_slope_pct", "암거 경사", "%"), + ): + o = opts[key] + assert (o.label, o.input, o.unit, o.default, o.required) == ( + label, + "number", + unit, + None, + False, + ) + assert o.empty_means # 손 입력 · 기본값 근거 없음 — 비워 둘 수 있음(B05 단계 필수 금지) + groups = load_definition()["그룹"][KEY] + assert groups == [ + {"id": GROUP, "이름": "맹암거", "옵션": list(opts), "형상": {}}, + ] + + +def test_정의_검사_통과(): + from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + + assert definition.check(load_definition()) == [] + + +def _item(**options): + return StructureInstance( + type_id=KEY, + placement="point", + chainage_m=63.5, + options=options, + group_items={ + GROUP: {"layer": "system", "owner": None, "key": "SS000001", "name": "맹암거"} + }, + ) + + +def test_저장_다시_읽기_그대로(tmp_path): + from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures + + item = _item(culvert_height_m=0.6, culvert_slope_pct=2.5) + save_structures(str(tmp_path), [item], base_revision=0) + _, loaded = load_structures(str(tmp_path)) + assert [(s.type_id, s.chainage_m, s.options) for s in loaded] == [ + (KEY, 63.5, {"culvert_height_m": 0.6, "culvert_slope_pct": 2.5}) + ] + assert loaded[0].group_items == item.group_items + + +@pytest.mark.parametrize( + "options", + [{"culvert_height_m": -1}, {"culvert_slope_pct": -0.5}], +) +def test_음수는_서버가_막음(tmp_path, options): + from B05_Profile.B05_Profile_Structures_Repository import save_structures + + with pytest.raises(ValueError): + save_structures(str(tmp_path), [_item(**options)], base_revision=0) diff --git a/resources/tester/test_b05_structures_define.py b/resources/tester/test_b05_structures_define.py index 1cb497663..dd67ca2c2 100644 --- a/resources/tester/test_b05_structures_define.py +++ b/resources/tester/test_b05_structures_define.py @@ -74,7 +74,7 @@ def test_정의_덧칸_얹기(out): def test_판1_정의는_지금_화면과_같음(out): - assert out["v1Types"] == 37 + assert out["v1Types"] == 38 # 52-6 횡단배수 맹암거 더함 assert out["v1Same"] is True assert out["v1Columns"] == [2] assert out["v1Anchor"] is True diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index d17cd6d55..c6fa19025 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -189,7 +189,7 @@ def test_quick_add_types_exclude_pipe_family(): def test_all_enabled_non_managed_types_are_quick_addable(): - """잔존 B05 선택지 = A군 2(노출형·개거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1. + """잔존 B05 선택지 = A군 3(노출형·개거·맹암거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1. 콘크리트 포장은 2026-08-28 사용자 지시로 되살렸다 — 포장 구간을 기준측점+길이+전/후로 받는 정본이 됐다(종단경사 자동 포장 적용을 대체). @@ -202,7 +202,7 @@ def test_all_enabled_non_managed_types_are_quick_addable(): 그래서 C군 구간형 시설과 같은 자리에 선다. (측구쪽 `ditch_berm` 은 우클릭 추가 대상이 아니라 여기 수에 안 잡힌다.) """ - assert len(_quick_add_types()) == 23 + assert len(_quick_add_types()) == 24 def test_removed_types_are_absent(): diff --git a/resources/tester/ui_checks/52-6_횡단맹암거.js b/resources/tester/ui_checks/52-6_횡단맹암거.js new file mode 100644 index 000000000..2ce14c7ee --- /dev/null +++ b/resources/tester/ui_checks/52-6_횡단맹암거.js @@ -0,0 +1,216 @@ +/* 52-6 — 횡단배수 › 맹암거(표 06 · 새 종류 `cross_underdrain`) · B05 · B06 「구조물 배치」 + * 시작 화면: B05 종단설계(#/b05-profile) · 아무것도 안 고름 · 측점 3+7 이 비어 있음 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m526 = "단계";` 를 붙여 + * orca eval --expression "window.__m526='edit'; $(cat 이 파일)" → 종류 목록 · 컨테이너 · 값 · 잇기 · [추가] · 다시 열기 + * orca eval --expression "window.__m526='b06'; $(cat 이 파일)" → B06 에서 같은 항목 다시 열기 · B05 와 같은 그리기 + * orca eval --expression "window.__m526='save'; $(cat 이 파일)" → [저장] 요청 가로챔(몸통만 봄) + * orca eval --expression "window.__m526='restore'; $(cat 이 파일)" → sessionStorage 를 처음 그대로 · 뒤에 orca reload + * 쓰는 것: 없음 — [저장] 은 fetch 를 가로채 서버로 안 보냄(공유 storage) · 서버 저장 왕복은 test_52_6_cross_underdrain + * 기대 수치(edit): b05Types 에 cross_underdrain:맹암거 · underdrain 없음 · box 맹암거 · keys 높이 · 경사 · + * pending 높이 0.6 · 경사 2.5 · 잇기 gcrossud 지정 · reopened 같음 + * 기대 수치(b06): b06Types A 에 맹암거 · B 에 종단배수 맹암거 따로 · sameDrawing · 값 같음 + * 기대 수치(save): saved = PUT 몸통에 같은 값(67 m · 0.6 · 2.5 · gcrossud) + */ +(async () => { + const MODE = window.__m526 || "edit"; + const KEY = "cross_underdrain"; + const GROUP = "gcrossud"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const vis = (n) => n && n.offsetParent !== null; + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts")); + const sec = () => document.querySelector(".b05-structure-section"); + const sels = () => sec().querySelectorAll(".b05-structure__grid select"); + const choose = async (sel, value) => { + sel.value = value; + sel.dispatchEvent(new Event("change", { bubbles: true })); + await w(300); + }; + const typesOf = async (group) => { + await choose(sels()[0], group); + return [...sels()[1].options].filter((o) => o.value).map((o) => `${o.value}:${o.text}`); + }; + const box = () => sec().querySelector(`[data-b05-group="${GROUP}"]`); + const input = (k) => box()?.querySelector(`[data-option-keys="${k}"] input`); + const shape = () => { + const b = box(); + return { + legend: b?.querySelector("legend").textContent, + keys: [...(b?.querySelectorAll("[data-option-keys]") ?? [])].map( + (n) => `${n.dataset.optionKeys}|${n.querySelector("span").textContent}`, + ), + values: ["culvert_height_m", "culvert_slope_pct"].map((k) => input(k)?.value), + link: b?.querySelector(".b05-structure__summary-name").textContent, + linked: b?.querySelector(".b05-structure__summary-name").classList.contains("is-assigned"), + }; + }; + const item = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /^3\+7.*맹암거/.test(l.textContent.trim()), + ); + const openItem = async () => { + await until(() => item()); + const li = item(); + (li.querySelector("button") ?? li).click(); + await w(600); + // 이미 고른 채로 열렸으면 누르면 풀림 — 풀렸으면 한 번 더(재선택 = 해제) + if (sels()[1].value !== KEY) (li.querySelector("button") ?? li).click(); + await until(() => vis(box())); + await w(300); + }; + const pickLink = async () => { + box().querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(500); + }; + const pendingMine = () => + (api.readPendingStructures(projectId) ?? []).filter((s) => s.type_id === KEY); + const out = { mode: MODE }; + + if (MODE === "edit") { + sessionStorage.setItem("__526_session", JSON.stringify({ ...sessionStorage })); + out.b05Types = await typesOf("A"); + out.b05HasLongUnderdrain = (await typesOf("B")).some((t) => t.startsWith("underdrain:")); + await typesOf("A"); + await choose(sels()[1], KEY); + out.boxBeforeStation = { shown: vis(box()), locked: !!box()?.querySelector(".is-locked") }; + const pos = [...sec().querySelectorAll(".b05-structure__position-row input")].filter(vis); + for (const [el, v] of [ + [pos[0], "3"], + [pos[1], "7"], + ]) { + el.value = v; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + } + await w(300); + for (const [k, v] of [ + ["culvert_height_m", "0.6"], + ["culvert_slope_pct", "2.5"], + ]) { + input(k).value = v; + input(k).dispatchEvent(new Event("input", { bubbles: true })); + input(k).dispatchEvent(new Event("change", { bubbles: true })); + } + await w(200); + await pickLink(); + out.form = shape(); + sec().querySelector(".is-primary").click(); // [추가] + await w(800); + const mine = pendingMine(); + out.pending = mine.map((s) => ({ + chainage_m: s.chainage_m, + options: s.options, + groups: Object.keys(s.group_items ?? {}), + })); + // 종단 알약 — 약자 · 색이 종단배수 맹암거(맹암거 · #1f7fb8)와 따로 + const types = await api.fetchStructureTypes(true); + const ids = new Set(mine.map((s) => s.structure_id)); + if (!document.querySelector(".b05-structure__mark")) { + [...document.querySelectorAll("button")] + .find((b) => b.textContent.trim() === "▲종단") + ?.click(); + await until(() => document.querySelector(".b05-structure__mark"), 8000); + } + out.marks = [...document.querySelectorAll(".b05-structure__mark")] + .filter((m) => ids.has(m.dataset.structureId)) + .map((m) => `${m.textContent}|${m.style.color}`); + out.longStyle = types.find((t) => t.type_id === "underdrain")?.style; + // 다시 열기 — 폼을 비우고 목록에서 다시 고름 + const li = item(); + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + await w(400); + await openItem(); + out.reopened = shape(); + out.reopenSame = JSON.stringify(out.reopened) === JSON.stringify(out.form); + } + + if (MODE === "save") { + // [저장] — 요청을 가로채 몸통만 봄(서버 · storage 안 감) · 뒤에 화면이 서버 목록을 다시 읽어 항목이 사라짐 + const real = window.fetch; + const sent = []; + window.fetch = async (url, init = {}) => { + const method = (init.method || "GET").toUpperCase(); + if (method !== "GET" && String(url).includes(`/projects/${projectId}/`)) { + sent.push({ url: String(url), method, body: init.body ? String(init.body) : "" }); + return new Response(JSON.stringify({ status: "success", revision: 0, count: 0 }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return real(url, init); + }; + try { + document.querySelector('[data-dock-action="save"]').click(); + await w(3000); + await until(() => !document.querySelector(".ui-overlay.is-active"), 20000); + } finally { + window.fetch = real; + } + const put = sent.find((r) => r.url.includes("/route/structures") && r.method === "PUT"); + const body = put ? JSON.parse(put.body) : null; + const saved = body?.structures.filter((s) => s.type_id === KEY) ?? []; + out.saved = saved.map((s) => ({ + chainage_m: s.chainage_m, + options: s.options, + groups: Object.keys(s.group_items ?? {}), + })); + out.sent = sent.map((r) => `${r.method} ${r.url.split("/api/")[1] ?? r.url}`); + } + + if (MODE === "b06") { + await openItem(); + const b05 = shape(); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await openItem(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + out.b06 = b06; + sels()[1].value = ""; + const li = item(); + (li.querySelector("button") ?? li).click(); // 고름 풂 + await w(300); + out.b06TypesA = await typesOf("A"); + out.b06TypesB = await typesOf("B"); + } + + if (MODE === "restore") { + const raw = sessionStorage.getItem("__526_session"); + if (!raw) return JSON.stringify({ error: "처음 sessionStorage 없음" }); + const before = JSON.parse(raw); + sessionStorage.clear(); + for (const [k, v] of Object.entries(before)) sessionStorage.setItem(k, v); + out.restored = JSON.stringify({ ...sessionStorage }) === JSON.stringify(before); + out.mine = pendingMine().length; + } + return JSON.stringify(out); +})(); From 1a53e045c502743cac46929ffdbe66b2fd94a5bf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:38:48 +0900 Subject: [PATCH 03/37] =?UTF-8?q?feat(design):=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=B0=B0=EC=88=98=20=EB=B0=95=EC=8A=A4=EC=95=94=EA=B1=B0(?= =?UTF-8?q?=ED=91=9C=2002=20=EA=B3=A0=EC=B9=9C=20=ED=8C=90)=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=20=ED=8F=BC=20=E2=80=94=20=EB=B3=B8?= =?UTF-8?q?=EC=B2=B4=20=C2=B7=20=EC=9C=A0=EC=9E=85=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=C2=B7=20=EC=9C=A0=EC=B6=9C=20=EA=B5=AC=EC=A1=B0=20(PLAN=2052-2?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 박스암거 명세(design_structure_box) — 본체 규격 2.0×2.0 · 3.0×3.0 · 사용자 입력(가로 × 세로 숫자 칸) · 유입 · 유출 구조 없음 · 날개벽 · 흙막이 · 컨테이너마다 구조물 조합 잇기(gboxbody · gboxin · gboxout) - 저장 키 = 옛 키 그대로(body_width_m · body_height_m · wing_in · wing_out) · 규격 칸은 저장 안 함(폭 · 높이로 되찾음) · 옛 「있음」 = 날개벽 · 옛 날개벽 치수는 화면에서 빼고 지난 정의 값 - 명세 등록(STRUCTURE_SPECS) · 배관 시험 헬퍼가 같은 폴더 명세를 옮겨 읽음 - B06 날개벽 설치 판정 한 곳(wingInstalled) — 흙막이 = 날개벽 안 세움 · 저장 글을 있음/없음으로 안 덮음 · 날개벽 조정 행은 명세 유입 · 유출 칸으로 - 정의 판 7 — box_culvert 이름 표 · 그룹 셋 · wing_in/out 선택지 · 판 6 이력 보관 - 시험 test_52_structure_spec_box · 레지스트리 · 새 구조물 기본값 기대값 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/design/design_structure_box.ts | 117 + A00_Common/design/design_structure_pipe.ts | 6 +- .../B05_Profile_UI_Drainage_Facility.ts | 150 +- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 12 - .../B05_Profile_UI_Drainage_Facility_Merge.ts | 3 +- .../B06_Section_UI_Page_Ford_Controls.ts | 11 +- .../B06_Section_UI_Page_Structures_Spec.ts | 5 +- common_util/common_util_culvert_sets.ts | 9 +- resources/master_template/구조물정의.json | 65 +- .../master_template/구조물정의_이력/판_6.json | 3359 +++++++++++++++++ resources/tester/helper_52_structure_spec.cjs | 4 +- .../tester/helper_52_structure_spec_box.cjs | 61 + .../tester/test_52_structure_spec_box.py | 90 + .../tester/test_b05_new_structure_defaults.py | 2 +- .../test_b05_structures_registry_policy.py | 5 +- 15 files changed, 3770 insertions(+), 129 deletions(-) create mode 100644 A00_Common/design/design_structure_box.ts create mode 100644 resources/master_template/구조물정의_이력/판_6.json create mode 100644 resources/tester/helper_52_structure_spec_box.cjs create mode 100644 resources/tester/test_52_structure_spec_box.py diff --git a/A00_Common/design/design_structure_box.ts b/A00_Common/design/design_structure_box.ts new file mode 100644 index 000000000..639a01159 --- /dev/null +++ b/A00_Common/design/design_structure_box.ts @@ -0,0 +1,117 @@ +/* ============================================================================= + * design_structure_box.ts + * 횡단배수 › 박스암거 명세 — 사용자 구조물 표 02 고친 판(`docs/raw/구조물정의_표/02_횡단배수_박스암거.md`) 그대로. + * + * 컨테이너 셋(모두 필수): 본체 · 유입 구조 · 유출 구조. 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. + * 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리): + * · 본체 = `body_width_m` · `body_height_m` 수 — 규격 칸은 저장 안 함(폭 · 높이로 되찾음 · `bodyOf`) + * · 유입 · 유출 구조 = `wing_in` · `wing_out`(없음 · 날개벽 · 흙막이 · 옛 「있음」 = 날개벽) + * — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝) + * 표 02 에 없는 옛 날개벽 치수 칸은 화면에서 빼고 값은 남김(지난 정의 값). + * import 없음(꼴만) — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { Instance, SpecItem, StructureSpec } from "./design_structure_spec"; + +export const BOX_CUSTOM = "사용자 입력"; +/** 본체 규격 → [가로, 세로](m) — 실무 관측 규격(2026-08-17 사용자 확정). */ +const BOX_PRESETS: Record = { + "2.0×2.0": [2, 2], + "3.0×3.0": [3, 3], +}; + +/** 유입 · 유출 구조 — 같은 선택지 · 키만 다름. */ +const structureItem = (key: string): SpecItem => ({ + id: "structure", + label: "구조", + kind: "select", + key, + choices: [ + { value: "없음", label: "없음" }, + { value: "날개벽", label: "날개벽" }, + { value: "흙막이", label: "흙막이" }, + ], +}); + +/** 사용자 입력일 때만 서는 숫자 칸. */ +const customSide = (id: string, label: string, key: string): SpecItem => ({ + id, + label, + kind: "number", + step: 0.1, + by: { item: "size", map: { [BOX_CUSTOM]: { key } } }, +}); + +export const BOX_SPEC: StructureSpec = { + typeId: "box_culvert", + containers: [ + { + id: "body", + title: "본체", + required: true, + link: "gboxbody", + items: [ + { + id: "size", + label: "본체 규격", + kind: "select", + choices: [ + ...Object.keys(BOX_PRESETS).map((value) => ({ + value, + label: value.replace("×", " × "), + })), + { value: BOX_CUSTOM, label: BOX_CUSTOM }, + ], + }, + customSide("width", "가로 (m)", "body_width_m"), + customSide("height", "세로 (m)", "body_height_m"), + ], + }, + { + id: "inlet", + title: "유입 구조", + required: true, + link: "gboxin", + items: [structureItem("wing_in")], + }, + { + id: "outlet", + title: "유출 구조", + required: true, + link: "gboxout", + items: [structureItem("wing_out")], + }, + ], + // 표 02 에 없는 옛 날개벽 치수 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: ["in", "out"].flatMap((side) => + ["height_m", "length_m", "angle_deg"].map((field) => `wing_${side}_${field}`), + ), +}; + +/** 옛 저장 → 폼에 줄 옵션 — 설치 「있음」 = 날개벽(같은 뜻 · 저장하면 새 값으로 굳음). */ +export function boxOptionsIn( + options: Record, +): Record { + const out = { ...options }; + for (const key of ["wing_in", "wing_out"]) if (out[key] === "있음") out[key] = "날개벽"; + return out; +} + +/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 아니면 사용자 입력 · 없으면 빈 값. */ +export function bodyOf(options: Record): Instance { + const width = Number(options.body_width_m); + const height = Number(options.body_height_m); + if (!(width > 0 && height > 0)) return { size: "" }; + const preset = Object.entries(BOX_PRESETS).find( + ([, [w, h]]) => Math.abs(w - width) < 0.05 && Math.abs(h - height) < 0.05, + ); + return preset + ? { size: preset[0] } + : { size: BOX_CUSTOM, width: String(width), height: String(height) }; +} + +/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음). */ +export function bodyOptions(body: Instance | undefined): Record { + const preset = BOX_PRESETS[body?.size ?? ""]; + return preset ? { body_width_m: preset[0], body_height_m: preset[1] } : {}; +} diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index c72f84c0b..b6ce54ae4 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; +import { BOX_SPEC } from "./design_structure_box"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -184,4 +185,7 @@ export const PIPE_SPEC: StructureSpec = { }; /** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */ -export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC }; +export const STRUCTURE_SPECS: Readonly> = { + pipe: PIPE_SPEC, + box_culvert: BOX_SPEC, +}; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index e7371d120..ab2fc9066 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -18,11 +18,9 @@ * 유입·유출 길이가 정해지면 위치 칸(시작·종료 측점)을 기준 − 유입 ~ 기준 + 유출로 * 자동 채운다. 형식·치수 제원은 B06/B07 필수 승격 대상이라 보이되 비필수다. * - * BOX암거(2026-08-17 사용자 확정): 본체는 실무 관측 규격 2.0×2.0·3.0×3.0을 프리셋 - * 셀렉트로 받고(기본 2.0×2.0), 그 밖의 규격만 "사용자 지정"으로 폭·높이를 직접 - * 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다. - * 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이 - * 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다. + * BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2 · `design_structure_box`) — 본체 규격 · + * 유입 · 유출 구조. 저장 키는 body_width_m·body_height_m 숫자 · wing_in·wing_out 그대로. + * 날개벽 조각(createWingFields · 설치 · 짧은쪽 높이 · 길이 · 각도)은 세월교 몫. * * 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py` * `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry. @@ -35,8 +33,6 @@ import type { } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { - BOX_SIZE_CUSTOM, - BOX_SIZE_PRESETS, createRevetSideGroup, createWingFields, grid, @@ -44,15 +40,19 @@ import { INLET_REVET_KEYS, type InletStructureKind, labeled, - numberInput, optionalSelect, OUTLET_REVET_KEYS, - putNumber, WING_IN_KEYS, WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; +import { + BOX_SPEC, + bodyOf, + bodyOptions, + boxOptionsIn, +} from "../A00_Common/design/design_structure_box"; import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -265,30 +265,28 @@ export function createFacilityOptionsForm( // ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ── let linkSource: SpecLinks | null = null; let chainage: number | null = null; - const pipeForm = createSpecForm( - { - get: () => linkSource?.get() ?? {}, - pick: (key) => linkSource?.pick(key), - clear: (key) => linkSource?.clear(key), - set: (key, item) => linkSource?.set(key, item), + const specLinks: SpecLinks = { + get: () => linkSource?.get() ?? {}, + pick: (key) => linkSource?.pick(key), + clear: (key) => linkSource?.clear(key), + set: (key, item) => linkSource?.set(key, item), + }; + const pipeForm = createSpecForm(specLinks, { + auto: (_c, item) => { + if (item.id !== "length") return ""; + const length = pipeLengthM(chainage); + return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); }, - { - auto: (_c, item) => { - if (item.id !== "length") return ""; - const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); - }, - hint: (c, item, state) => { - const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; - return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; - }, - onChange: (c, item) => { - if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) - inletStructureHandler?.(inletStructureOf()); - emit(); - }, + hint: (c, item, state) => { + const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; + return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; }, - ); + onChange: (c, item) => { + if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) + inletStructureHandler?.(inletStructureOf()); + emit(); + }, + }); // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4) const inletSlot = pipeForm.slot("inlet"); const outletSlot = pipeForm.slot("outlet"); @@ -351,40 +349,14 @@ export function createFacilityOptionsForm( return merged; }; - // ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ──── - // 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정). - const boxSize = optionalSelect("— 안 정함 —", [ - ...BOX_SIZE_PRESETS.map(([label]) => label), - BOX_SIZE_CUSTOM, - ]); - const boxWidth = numberInput("0.1", "0"); - const boxHeight = numberInput("0.1", "0"); - const boxCustomRow = grid( - labeled("본체 폭 (m)", boxWidth, "body_width_m"), - labeled("본체 높이 (m)", boxHeight, "body_height_m"), - ); - const boxWrap = document.createElement("div"); - boxWrap.append( - grid(labeled("본체 규격 (m)", boxSize, ["body_width_m", "body_height_m"])), - boxCustomRow, - ); + // ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ── + const boxForm = createSpecForm(specLinks, { onChange: () => emit() }); + // 날개벽 칸(세월교 몫) — 세월교 · BOX 가 같은 조각을 쓰던 자리 · BOX 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); const hiddenOptions = createHiddenOptions(root); - /** 고른 프리셋을 폭·높이 칸에 실어 둔다 — 저장도, "사용자 지정"으로 바꿨을 때의 - * 출발값도 이 두 칸이 정본이다. 사용자 지정 상태에서는 손대지 않는다. */ - function syncBoxSize(): void { - const preset = BOX_SIZE_PRESETS.find(([label]) => label === boxSize.value); - if (preset) { - boxWidth.value = String(preset[1]); - boxHeight.value = String(preset[2]); - } - // 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로. - boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM; - } - // ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`). // 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7). const ford = createFordFields(() => emit()); @@ -409,7 +381,7 @@ export function createFacilityOptionsForm( revetTopRow, revetInlet.root, revetOutlet.root, - boxWrap, + boxForm.root, wingInFields.root, wingOutFields.root, ); @@ -423,9 +395,13 @@ export function createFacilityOptionsForm( const isBox = current === "box_culvert"; pipeRow.hidden = current !== "ford_bridge"; pipeForm.root.hidden = !isPipe; - boxWrap.hidden = !isBox; - // 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정). - const hasWing = isBox || current === "ford_bridge"; + boxForm.root.hidden = !isBox; + const hasWing = current === "ford_bridge"; + // B06 날개벽 조정 행 자리 — BOX 면 명세 유입 · 유출 구조 칸 끝, 아니면 날개벽 칸 끝 + (isBox ? boxForm.slot("inlet") : wingInFields.root.lastElementChild!).append(wingInFields.slot); + (isBox ? boxForm.slot("outlet") : wingOutFields.root.lastElementChild!).append( + wingOutFields.slot, + ); wingInFields.root.hidden = !hasWing; wingOutFields.root.hidden = !hasWing; const isFord = current === "ford_pavement" || current === "ford_bridge"; @@ -446,7 +422,6 @@ export function createFacilityOptionsForm( // 선택이 풀리면 스스로 사라진다. extraGroup.root.hidden = extraSlot.childElementCount === 0; if (isFord) ford.sync(); - if (isBox) syncBoxSize(); if (hasWing) { wingInFields.syncVisibility(); wingOutFields.syncVisibility(); @@ -464,9 +439,6 @@ export function createFacilityOptionsForm( [ pipeMaterial, pipeDiameter, - boxSize, - boxWidth, - boxHeight, ...wingInFields.inputs, ...wingOutFields.inputs, ...ford.inputs, @@ -500,8 +472,12 @@ export function createFacilityOptionsForm( bindLinks(links) { linkSource = links; pipeForm.paintLinks(); + boxForm.paintLinks(); + }, + paintLinks: () => { + pipeForm.paintLinks(); + boxForm.paintLinks(); }, - paintLinks: () => pipeForm.paintLinks(), setChainage(chainageM) { chainage = chainageM; if (current === "pipe") pipeForm.refresh(); @@ -525,28 +501,15 @@ export function createFacilityOptionsForm( // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). pipeMaterial.value = text("pipe_kind"); pipeDiameter.value = text("pipe_diameter_mm"); - if (facility === "pipe") { - const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); - pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups); + const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); + const names = definition?.spec_names ?? {}; + if (facility === "pipe") pipeForm.write(PIPE_SPEC, options, names, groups); + if (facility === "box_culvert") { + // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 · 옛 설치 「있음」 = 날개벽 + boxForm.write(BOX_SPEC, boxOptionsIn(options), names, groups); + for (const [id, value] of Object.entries(bodyOf(options))) + boxForm.setValue("body", id, value); } - // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. - // 값이 없으면 「안 정함」. - const savedWidth = Number.parseFloat(text("body_width_m")); - const savedHeight = Number.parseFloat(text("body_height_m")); - const hasSaved = - Number.isFinite(savedWidth) && - savedWidth > 0 && - Number.isFinite(savedHeight) && - savedHeight > 0; - const preset = hasSaved - ? BOX_SIZE_PRESETS.find( - ([, width, height]) => - Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05, - ) - : undefined; - boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : ""; - boxWidth.value = hasSaved ? String(savedWidth) : ""; - boxHeight.value = hasSaved ? String(savedHeight) : ""; wingInFields.write(options); wingOutFields.write(options); if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options); @@ -570,11 +533,8 @@ export function createFacilityOptionsForm( if (current === "pipe") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) } else if (current === "box_culvert") { - // 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다. - putNumber(options, "body_width_m", boxWidth.value); - putNumber(options, "body_height_m", boxHeight.value); - wingInFields.read(options); - wingOutFields.read(options); + // 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2) + Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0])); } else if (current === "ford_pavement") { ford.read(current, options); } else if (current === "ford_bridge") { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index 69906964b..c001a7137 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -461,18 +461,6 @@ export function createBasinSplitFields(length: HTMLInputElement): { }; } -/* ── BOX암거 ────────────────────────────────────────────────────────────── */ - -/** 본체 규격 프리셋 — 실무 관측 규격이다(2026-08-17 사용자 확정). 그 밖의 규격은 - * "사용자 지정"으로 폭·높이를 직접 받는다. 저장 키는 프리셋이든 자유 입력이든 - * body_width_m·body_height_m 숫자 그대로다 — 프리셋 전용 키를 만들지 않아야 - * 하류(B06 횡단도·수량)가 폭·높이만 읽고 끝난다. */ -export const BOX_SIZE_PRESETS: ReadonlyArray = [ - ["2.0×2.0", 2, 2], - ["3.0×3.0", 3, 3], -]; -export const BOX_SIZE_CUSTOM = "사용자 지정"; - /** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */ interface WingKeys { install: string; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 4e6afd657..c0884fffd 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -40,7 +40,8 @@ export const FORM_OPTION_KEYS: Readonly> = { "outlet_type", ...revetKeys("outlet"), ], - box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")], + // BOX = 표 02 명세 키(PLAN 52-2) — 옛 날개벽 치수는 폼에서 빠져 이어 붙임(지난 정의 값) + box_culvert: ["body_width_m", "body_height_m", "wing_in", "wing_out"], ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], ford_bridge: [ "pipe_kind", diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index e8c45dd44..bec9877ef 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -8,7 +8,12 @@ * 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다. * ========================================================================== */ -import { boxSpanM, pipeDiameterM, wingSlabExtendM } from "@util/common_util_culvert_sets"; +import { + boxSpanM, + pipeDiameterM, + wingInstalled, + wingSlabExtendM, +} from "@util/common_util_culvert_sets"; import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford"; import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford"; @@ -76,8 +81,8 @@ function wingPatchFrom(patch: Record, prefix: string): return Number.isFinite(value) ? value : undefined; }; const wing: WingPatch = {}; - // 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약). - if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음"; + // 설치 값 글(있음 · 없음 · BOX 날개벽 · 흙막이) — 백엔드 `_wing_spec` 과 같은 판정. + if (patch[prefix] !== undefined) wing.installed = wingInstalled(patch[prefix]); const heightM = num(`${prefix}_height_m`); if (heightM !== undefined) wing.height_m = heightM; const lengthM = num(`${prefix}_length_m`); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts index e4e81cc7b..e94db0ef9 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts @@ -8,6 +8,7 @@ import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend"; import type { StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { wingInstalled } from "@util/common_util_culvert_sets"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; type Merged = Record; @@ -50,7 +51,9 @@ function keepWings( for (const [wing, prefix] of wings) { // 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45. const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음"; - keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install); + // 저장 글이 그린 설치와 같은 뜻이면 그대로 둠(BOX 유입·유출 구조 날개벽 · 흙막이 글을 안 덮음) + if (stored?.[prefix] === undefined || wingInstalled(stored[prefix]) !== wing.installed) + keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install); keepSpec( merged, stored, diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index d53993a58..b9fb617e7 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -231,6 +231,12 @@ export function wingSlabExtendM( * 날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량. * 연장량 = 길이 × cos(각도) — 각도는 관축(계류 방향) 기준 벌어짐각이다. */ +/** 날개벽을 세우는 설치 값인가 — BOX암거 유입·유출 구조(표 02) 없음 · 날개벽 · 흙막이 · 옛 있음 = 날개벽. + * 날개벽만 세움 · 빈 값은 세움(기본). 파이썬 `_wing_spec` 과 짝. */ +export function wingInstalled(value: unknown): boolean { + return !["없음", "흙막이"].includes(String(value ?? "").trim()); +} + function wingSpec( values: Record, defaults: Record, @@ -241,8 +247,7 @@ function wingSpec( const length = num(values[`${prefix}_length_m`], num(defaults[`${prefix}_length_m`], 0.0)); const angle = num(values[`${prefix}_angle_deg`], num(defaults[`${prefix}_angle_deg`], 45.0)); const height = num(values[`${prefix}_height_m`], num(defaults[`${prefix}_height_m`], 0.0)); - // BOX암거 유입·유출 구조(표 02) — 없음 · 날개벽 · 흙막이 · 옛 값 있음 = 날개벽. 날개벽만 세움. - const installed = !["없음", "흙막이"].includes(String(install ?? "").trim()); + const installed = wingInstalled(install); return { installed, height_m: height, diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 5b2e2bf07..01607d917 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 6, + "판": 7, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -382,13 +382,14 @@ }, { "key": "wing_in", - "label": "날개벽(유입)", + "label": "유입 구조", "input": "select", "choices": [ - "있음", - "없음" + "없음", + "날개벽", + "흙막이" ], - "default": "있음", + "default": "날개벽", "required": false }, { @@ -417,13 +418,14 @@ }, { "key": "wing_out", - "label": "날개벽(유출)", + "label": "유출 구조", "input": "select", "choices": [ - "있음", - "없음" + "없음", + "날개벽", + "흙막이" ], - "default": "있음", + "default": "날개벽", "required": false }, { @@ -3295,7 +3297,23 @@ "계곡통과시설", "BOX_본체규격", "날개벽" - ] + ], + "이름": { + "body.size": "본체 규격", + "body.size=2.0×2.0": "2.0 × 2.0", + "body.size=3.0×3.0": "3.0 × 3.0", + "body.size=사용자 입력": "사용자 입력", + "body.width": "가로 (m)", + "body.height": "세로 (m)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이" + } }, "ford_pavement": { "동작": [ @@ -3352,6 +3370,33 @@ "옵션": [], "형상": {} } + ], + "box_culvert": [ + { + "id": "gboxbody", + "이름": "본체", + "옵션": [ + "body_width_m", + "body_height_m" + ], + "형상": {} + }, + { + "id": "gboxin", + "이름": "유입 구조", + "옵션": [ + "wing_in" + ], + "형상": {} + }, + { + "id": "gboxout", + "이름": "유출 구조", + "옵션": [ + "wing_out" + ], + "형상": {} + } ] }, "배치": {}, diff --git a/resources/master_template/구조물정의_이력/판_6.json b/resources/master_template/구조물정의_이력/판_6.json new file mode 100644 index 000000000..5b2e2bf07 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_6.json @@ -0,0 +1,3359 @@ +{ + "판": 6, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": null, + "required": false + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "ditch_spec", + "label": "규격", + "input": "select", + "choices": [ + "콘크리트 개거 150×200", + "L형수로 H=0.2" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎" + }, + { + "key": "length_m", + "label": "연장", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)" + } + ] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "콘크리트", + "돌(찰)", + "돌(메)", + "블록쌓기", + "돌망태", + "흙포대", + "통나무쌓기", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(구 사토장)", + "placement": "interval", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "쌓는 쪽", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "required": false, + "phase": "b05", + "default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈" + }, + { + "key": "fill_slope_ratio", + "label": "비탈 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리" + }, + { + "key": "extra_distance_m", + "label": "추가 운반거리", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "detail", + "default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.structure": "구조", + "soil_add.structure=기슭막이": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ] + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ] + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": { + "pipe": { + "pipe_kind": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index 0ff82723b..03bfa0098 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -13,7 +13,9 @@ const load = (file, deps = {}) => { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const box = { exports: {} }; - new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? require(m)); + // 같은 폴더 명세(./design_structure_*)는 같은 식으로 옮겨 읽음 + const need = (m) => deps[m] ?? (m.startsWith("./") ? load(`${m.slice(2)}.ts`) : require(m)); + new Function("exports", "module", "require", js)(box.exports, box, need); return box.exports; }; const s = load("design_structure_spec.ts"); diff --git a/resources/tester/helper_52_structure_spec_box.cjs b/resources/tester/helper_52_structure_spec_box.cjs new file mode 100644 index 000000000..e86007804 --- /dev/null +++ b/resources/tester/helper_52_structure_spec_box.cjs @@ -0,0 +1,61 @@ +/* 박스암거 명세 단위검증 헬퍼(PLAN 52-2) — `design_structure_spec.ts` · `design_structure_box.ts` 를 + * 그 자리에서 트랜스파일해 Node 로 돌림(꼴만 import). 결과 = JSON 한 벌. + * 인자 `names` 면 박스암거 이름 표(정의 `종류.box_culvert.이름`)만 냄. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const load = (file) => { + const src = fs.readFileSync(path.join(ROOT, "A00_Common", "design", file), "utf8"); + const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, require); + return box.exports; +}; +const s = load("design_structure_spec.ts"); +const b = load("design_structure_box.ts"); +const SPEC = b.BOX_SPEC; + +if (process.argv[2] === "names") { + process.stdout.write(JSON.stringify(s.nameTable(SPEC))); + process.exit(0); +} + +/** 화면이 하는 차례 그대로 — 옛 값 정리 → 명세 읽기 → 본체 칸 되찾기 → 저장 옵션. */ +const roundTrip = (options, edit) => { + const state = s.readState(SPEC, b.boxOptionsIn(options)); + Object.assign(state.body[0], b.bodyOf(options)); + edit?.(state); + return { state, out: { ...s.writeOptions(SPEC, state), ...b.bodyOptions(state.body[0]) } }; +}; +const body = SPEC.containers.find((c) => c.id === "body"); +const width = body.items.find((i) => i.id === "width"); + +const old = { body_width_m: 3, body_height_m: 2.5, wing_in: "있음", wing_in_length_m: 2 }; +const out = { + containers: SPEC.containers.map((c) => [c.id, c.title, c.required]), + links: SPEC.containers.map((c) => s.linkOf(c, 1)), + sizes: body.items[0].choices.map((c) => c.value), + structures: SPEC.containers[1].items[0].choices.map((c) => c.value), + customHidden: s.branchOf(width, { size: "2.0×2.0" }), + customShown: s.branchOf(width, { size: "사용자 입력" }), + old: roundTrip(old), + preset: roundTrip({ body_width_m: 2, body_height_m: 2, wing_out: "없음" }), + toPreset: roundTrip(old, (st) => { + st.body[0].size = "3.0×3.0"; + st.outlet[0].structure = "흙막이"; + }), + toCustom: roundTrip({ body_width_m: 2, body_height_m: 2 }, (st) => { + st.body[0].size = "사용자 입력"; + st.body[0].width = "2.4"; + st.body[0].height = "1.8"; + }), + blank: roundTrip({}), + keys: s.specKeys(SPEC), + retired: SPEC.retired, +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_52_structure_spec_box.py b/resources/tester/test_52_structure_spec_box.py new file mode 100644 index 000000000..95a9307a9 --- /dev/null +++ b/resources/tester/test_52_structure_spec_box.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""횡단배수 › 박스암거(표 02 고친 판) 명세 — PLAN 52-2. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec_box.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-2_박스암거_컨테이너.js`. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + + +def _node(*args): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec_box.cjs"), *args], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out(): + return _node() + + +def test_컨테이너_셋_모두_필수(out): + assert out["containers"] == [ + ["body", "본체", True], + ["inlet", "유입 구조", True], + ["outlet", "유출 구조", True], + ] + assert out["links"] == ["gboxbody", "gboxin", "gboxout"] + + +def test_선택지_표_02_그대로(out): + assert out["sizes"] == ["2.0×2.0", "3.0×3.0", "사용자 입력"] + assert out["structures"] == ["없음", "날개벽", "흙막이"] + + +def test_사용자_입력일_때만_가로_세로_칸(out): + assert out["customHidden"] is None + assert out["customShown"] == {"key": "body_width_m"} + + +def test_옛_값_읽기_규격_밖은_사용자_입력_있음은_날개벽(out): + st = out["old"]["state"] + assert st["body"][0] == {"size": "사용자 입력", "width": "3", "height": "2.5"} + assert st["inlet"][0]["structure"] == "날개벽" + back = out["old"]["out"] + assert back == {"body_width_m": 3, "body_height_m": 2.5, "wing_in": "날개벽"} + # 옛 날개벽 치수는 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값) + assert "wing_in_length_m" not in back + assert "wing_in_length_m" in out["retired"] + + +def test_규격은_폭_높이_수로_저장_규격_칸은_저장_안_함(out): + assert out["preset"]["state"]["body"][0]["size"] == "2.0×2.0" + assert out["preset"]["out"] == {"body_width_m": 2, "body_height_m": 2, "wing_out": "없음"} + to = out["toPreset"]["out"] + assert to["body_width_m"] == 3 and to["body_height_m"] == 3 + assert to["wing_out"] == "흙막이" + assert out["toCustom"]["out"] == {"body_width_m": 2.4, "body_height_m": 1.8} + assert "size" not in json.dumps(out["keys"]) + + +def test_빈_값은_빈_채로(out): + assert out["blank"]["out"] == {} + assert out["blank"]["state"]["body"][0]["size"] == "" + + +def test_정의_파일에_이름_표_그룹_선택지가_올라_있음(): + names = _node("names") + with open( + os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" + ) as f: + doc = json.load(f) + assert set(names) == set(doc["종류"]["box_culvert"]["이름"]) + assert [g["id"] for g in doc["그룹"]["box_culvert"]] == ["gboxbody", "gboxin", "gboxout"] + box = next(t for t in doc["types"] if t["type_id"] == "box_culvert") + wing = {o["key"]: o for o in box["options"]} + for key in ("wing_in", "wing_out"): + assert wing[key]["choices"] == ["없음", "날개벽", "흙막이"] diff --git a/resources/tester/test_b05_new_structure_defaults.py b/resources/tester/test_b05_new_structure_defaults.py index 4fcdd9ed2..823499553 100644 --- a/resources/tester/test_b05_new_structure_defaults.py +++ b/resources/tester/test_b05_new_structure_defaults.py @@ -38,7 +38,7 @@ def test_새_관만_기본값_빈_키에만(): assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "기슭막이" box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert") basins._apply_new_defaults([box]) - assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "있음" + assert box.options["body_width_m"] == 2.0 and box.options["wing_in"] == "날개벽" def test_원본에_있던_관은_빈칸_그대로(monkeypatch): diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index 2f2c006c7..5d67c89fe 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -544,8 +544,9 @@ def test_box_culvert_wing_wall_options_defined(): for side in ("in", "out"): toggle = options[f"wing_{side}"] assert toggle.phase == "b05" and not toggle.required - assert toggle.choices == ["있음", "없음"] - assert toggle.default == "있음", f"wing_{side} 기본 설치" + # 표 02 고친 판(PLAN 52-2) — 유입 · 유출 구조 = 없음 · 날개벽 · 흙막이 + assert toggle.choices == ["없음", "날개벽", "흙막이"] + assert toggle.default == "날개벽", f"wing_{side} 기본 구조" for dim, value in expected.items(): option = options[f"wing_{side}_{dim}"] assert option.phase == "b05" and not option.required, f"wing_{side}_{dim}" From 103a207cee1cebe54104ee14e7288e07e6e42e19 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:42:11 +0900 Subject: [PATCH 04/37] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=A0=95=EC=9D=98=20=3D=20=EB=AA=85?= =?UTF-8?q?=EC=84=B8=20=EC=9D=B4=EB=A6=84=20=EA=B3=A0=EC=B9=98=EA=B8=B0=20?= =?UTF-8?q?=EC=B9=B8(=EB=AA=85=EC=84=B8=20=EC=9E=88=EB=8A=94=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=C2=B7=2052-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세가 있는 종류(STRUCTURE_SPECS)는 컨테이너 제목 › 항목 이름 › 값 이름 나무 · 이름만 고침 · 키 · 필수/선택 · 꼴은 읽기만 - 필수 컨테이너 제목 = 정의 그룹 이름 자리 · 나머지 = 종류.이름 표(B05 · B06 그리기와 같은 차례) - 미리보기 = B05 · B06 명세 폼에 고친 이름 · 빈 이름은 저장 막음 · [저장] = 새 판 + 이력(48장 길 그대로) - 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점)는 명세 종류에서 숨김 · 명세 없는 종류는 그대로 - 시험: test_m02_so_def_spec(정의 사본) · ui_checks/52-11_정의_이름고치기 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../StructOptions/M02_SO_Def.ts | 40 +++-- .../StructOptions/M02_SO_Def_Model.ts | 7 +- .../StructOptions/M02_SO_Def_Spec.ts | 67 ++++++++ .../StructOptions/M02_SO_Def_Spec_Model.ts | 91 ++++++++++ .../StructOptions/M02_SO_Style.css | 25 +++ .../StructOptions/M02_SO_Text.ts | 9 + resources/tester/helper_m02_so_def_spec.cjs | 64 ++++++++ resources/tester/test_m02_so_def_spec.py | 58 +++++++ .../tester/ui_checks/52-11_정의_이름고치기.js | 155 ++++++++++++++++++ 9 files changed, 501 insertions(+), 15 deletions(-) create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts create mode 100644 M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts create mode 100644 resources/tester/helper_m02_so_def_spec.cjs create mode 100644 resources/tester/test_m02_so_def_spec.py create mode 100644 resources/tester/ui_checks/52-11_정의_이름고치기.js diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index 26899fdc1..d16428ac1 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -26,6 +26,8 @@ import { } from "./M02_SO_Def_Model"; import { buildOptionsBlock } from "./M02_SO_Def_Options"; import { buildRulesBlock } from "./M02_SO_Def_Rules"; +import { buildSpecBlock } from "./M02_SO_Def_Spec"; +import { STRUCTURE_SPECS } from "../../A00_Common/design/design_structure_pipe"; import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { buildGroupBlock } from "./M02_SO_Groups"; import { to } from "./M02_SO_Text"; @@ -182,6 +184,8 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { } const edit = canEdit(); const extra = (): NonNullable => (d.종류[type.type_id] ??= {}); + // 명세가 있는 종류(PLAN 52) = 이름만 고침 — 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점)는 숨김 + const spec = STRUCTURE_SPECS[type.type_id]; detail.replaceChildren( el("div", { className: "m02-sod__kindhead", @@ -196,16 +200,20 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { edit, attr: "data-sod-kind-off", }), - checkBox( - to("BaseStationShow"), - baseShown(d, type.type_id), - (v) => { - if (v) delete extra().기준측점; - else extra().기준측점 = false; - changed(); - }, - { edit, attr: "data-sod-base" }, - ), + ...(spec + ? [] + : [ + checkBox( + to("BaseStationShow"), + baseShown(d, type.type_id), + (v) => { + if (v) delete extra().기준측점; + else extra().기준측점 = false; + changed(); + }, + { edit, attr: "data-sod-base" }, + ), + ]), ...(d.종류[type.type_id]?.동작 ?? []).map((a) => { const tag = createTag(a, "neutral"); tag.title = d.동작[a] ?? ""; @@ -213,10 +221,14 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { }), ], }), - buildOptionsBlock({ def: d, type, edit, open, changed }), - buildLayoutBlock({ def: d, type, edit, changed }), - buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }), - buildRulesBlock({ def: d, type, edit, changed }), + ...(spec + ? [buildSpecBlock({ def: d, type, spec, edit, changed })] + : [ + buildOptionsBlock({ def: d, type, edit, open, changed }), + buildLayoutBlock({ def: d, type, edit, changed }), + buildGroupBlock({ list: (d.그룹[type.type_id] ??= []), edit, changed }), + buildRulesBlock({ def: d, type, edit, changed }), + ]), ); }; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts index d45d9511b..2cb6355bb 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Model.ts @@ -57,7 +57,8 @@ export interface StructDef { 구조물군: Record; 동작: Record; types: TypeDef[]; - 종류: Record; + /** 이름 = 구조물 명세 이름 바꿈 표(PLAN 52 · 키 `{컨테이너}[.{항목}[={값}]]`) */ + 종류: Record }>; 표시: Record>; 그룹: Record; 배치: Record>; @@ -257,6 +258,10 @@ export function problems(def: StructDef): string[] { if (!o.choices?.length) out.push(`${t.name}.${o.label} 고르기 꼴은 선택지가 있어야 함`); } const grp = badNames((def.그룹[t.type_id] ?? []).map((g) => g.이름)); + const spec = Object.entries(def.종류[t.type_id]?.이름 ?? {}) + .filter(([, v]) => !v.trim() || v.length > NAME_MAX) + .map(([k]) => k); + if (spec.length) out.push(`${t.name} 명세 이름: ${spec.join(", ")}`); if (grp.length) out.push(`${t.name} 그룹 이름: ${grp.join(", ")}`); const lap = overlaps(placeAll(t.options, def.배치[t.type_id])); if (lap.length) out.push(`${t.name} 배치 겹침: ${lap.join(", ")}`); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts new file mode 100644 index 000000000..63157ee50 --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * M02_SO_Def_Spec.ts + * 구조물 명세가 있는 종류(PLAN 52 · `STRUCTURE_SPECS`)의 이름 고치기 칸(52-11) — 나무 꼴 + * 컨테이너 제목 › 항목 이름 › 값 이름 · 이름만 고침 · 키 · 필수/선택 · 꼴은 읽기만 · + * 아래 미리보기 = B05 · B06 이 그리는 같은 명세 폼(`createSpecForm`)에 고친 이름을 얹음. + * 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침. + * ========================================================================== */ + +import { createTag, el } from "@ui/ui_template_elements"; +import { createSpecForm } from "../../A00_Common/design/design_structure_form"; +import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; +import { setSpecName, specNameRows, type SpecNameRow } from "./M02_SO_Def_Spec_Model"; +import { head, textBox } from "./M02_SO_Def_Ui"; +import { to, type OptionsTextKey } from "./M02_SO_Text"; + +const TAG: Record, OptionsTextKey> = { + required: "SpecRequired", + optional: "SpecOptional", + select: "FormSelect", + number: "FormNumber", + auto: "FormAuto", +}; + +export function buildSpecBlock(opt: { + def: StructDef; + type: TypeDef; + spec: StructureSpec; + edit: boolean; + changed: () => void; +}): HTMLElement { + const { def, type, spec, edit, changed } = opt; + const rows = specNameRows(def, spec).map((row) => + el("div", { + className: `m02-sod__specrow is-level-${row.level}`, + attrs: { "data-sod-spec": row.nameKey }, + children: [ + textBox(row.label, (v) => setSpecName(def, type.type_id, row, v), changed, { + edit, + attr: "data-sod-spec-name", + cls: "m02-sod__name", + }), + el("small", { className: "m02-opts__key", text: row.key }), + ...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []), + ], + }), + ); + // 미리보기 — 값 없는 새 구조물 폼 · 잇기 줄 없음(조합은 프로젝트 몫) + const preview = createSpecForm(null); + const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름])); + preview.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups); + preview.root.inert = true; + return el("div", { + className: "m02-sod__block", + attrs: { "data-sod-spec-block": "" }, + children: [ + ...head(to("SpecHead"), to("SpecNote")), + ...rows, + ...head(to("SpecPreview")), + el("div", { + className: "m02-sod__specpreview", + attrs: { "data-sod-spec-preview": "" }, + children: [preview.root], + }), + ], + }); +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts new file mode 100644 index 000000000..bd6bc693b --- /dev/null +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts @@ -0,0 +1,91 @@ +/* ============================================================================= + * M02_SO_Def_Spec_Model.ts + * 구조물 명세(PLAN 52 · `design_structure_spec`) 이름 고치기(52-11)의 자료 손질 — 화면 그리기 없음. + * + * · 명세 = 구조 정본(키 · 컨테이너 · 필수/선택 · 꼴) — 여기서는 **이름만** 고침 + * · 이름 자리 = 정의 `종류.{종류}.이름`(키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`) + * · 필수 컨테이너 제목 = 이름 표에 없으면 정의 그룹(`그룹.{종류}` 의 `id = link`) 이름 — B05 그리기 + * (`containerTitle`)와 같은 차례로 읽고 그 자리에 씀 + * import 는 꼴만 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import type { StructDef } from "./M02_SO_Def_Model"; + +export interface SpecNameRow { + /** 0 = 컨테이너 · 1 = 항목 · 2 = 값 */ + level: 0 | 1 | 2; + /** 이름 표 키 */ + nameKey: string; + /** 읽기만 표시 — 컨테이너 id · 항목 id · 값 */ + key: string; + /** 지금 이름 */ + label: string; + /** 읽기만 표시 — 필수/선택 · 꼴 */ + tag: "required" | "optional" | "select" | "number" | "auto" | null; + /** 필수 컨테이너 제목이 정의 그룹 이름에서 옴 — 고치면 그 그룹 이름에 씀 */ + group: string | null; +} + +/** 명세 차례대로 이름 줄 — 같은 이름 키(갈래마다 겹친 값)는 한 줄. */ +export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] { + const names = def.종류[spec.typeId]?.이름 ?? {}; + const groups = def.그룹[spec.typeId] ?? []; + const rows: SpecNameRow[] = []; + const seen = new Set(); + const push = (row: SpecNameRow): void => { + if (seen.has(row.nameKey)) return; + seen.add(row.nameKey); + rows.push(row); + }; + for (const c of spec.containers) { + const group = + c.required && names[c.id] === undefined ? groups.find((g) => g.id === c.link) : undefined; + push({ + level: 0, + nameKey: c.id, + key: c.id, + label: names[c.id] ?? group?.이름 ?? c.title, + tag: c.required ? "required" : "optional", + group: group ? group.id : null, + }); + for (const item of c.items) { + const itemKey = `${c.id}.${item.id}`; + push({ + level: 1, + nameKey: itemKey, + key: item.id, + label: names[itemKey] ?? item.label, + tag: item.kind, + group: null, + }); + const choices = [ + ...(item.choices ?? []), + ...Object.values(item.by?.map ?? {}).flatMap((b) => b.choices ?? []), + ]; + for (const ch of choices) { + const choiceKey = `${itemKey}=${ch.value}`; + push({ + level: 2, + nameKey: choiceKey, + key: ch.value, + label: names[choiceKey] ?? ch.label, + tag: null, + group: null, + }); + } + } + } + return rows; +} + +/** 이름 하나를 사본에 씀 — 그룹에서 온 제목은 그 그룹 이름 · 나머지는 이름 표. */ +export function setSpecName(def: StructDef, typeId: string, row: SpecNameRow, value: string): void { + const group = row.group ? def.그룹[typeId]?.find((g) => g.id === row.group) : undefined; + if (group) { + group.이름 = value; + return; + } + const kind = (def.종류[typeId] ??= {}); + (kind.이름 ??= {})[row.nameKey] = value; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Style.css b/M02_MasterTemplete/StructOptions/M02_SO_Style.css index a8570c86a..492eb8c4f 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Style.css +++ b/M02_MasterTemplete/StructOptions/M02_SO_Style.css @@ -332,3 +332,28 @@ margin: 0; color: var(--color-danger); } + +/* 구조물 명세 이름 고치기(PLAN 52-11) — 컨테이너 › 항목 › 값 들여쓰기 */ +.m02-sod__specrow { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-sod__specrow.is-level-0 { + margin-top: var(--spacing-8); + font-weight: 600; +} + +.m02-sod__specrow.is-level-1 { + padding-left: 1.5rem; +} + +.m02-sod__specrow.is-level-2 { + padding-left: 3rem; +} + +.m02-sod__specpreview { + max-width: 28rem; +} diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 90037edd8..3235e7762 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -45,6 +45,15 @@ const TEXT = { FormNumber: ["숫자", "Number"], FormText: ["글", "Text"], FormBool: ["예/아니오", "Yes/No"], + FormAuto: ["자동", "Auto"], + SpecHead: ["구조물 명세 이름", "Structure spec names"], + SpecNote: [ + "컨테이너 제목 › 항목 이름 › 값 이름만 고침 — 키(작은 글) · 필수/선택 · 꼴 · 구조는 구조물 명세가 정함 · [저장]하면 B05 · B06 이 이 이름으로 그림", + "Rename containers › items › values only — keys, required/optional, form and structure come from the spec", + ], + SpecRequired: ["필수", "Required"], + SpecOptional: ["선택", "Optional"], + SpecPreview: ["미리보기 — B05 · B06 폼", "Preview — B05 · B06 form"], Yes: ["예", "Yes"], No: ["아니오", "No"], Unit: ["단위", "Unit"], diff --git a/resources/tester/helper_m02_so_def_spec.cjs b/resources/tester/helper_m02_so_def_spec.cjs new file mode 100644 index 000000000..ede10f95b --- /dev/null +++ b/resources/tester/helper_m02_so_def_spec.cjs @@ -0,0 +1,64 @@ +/* M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증 헬퍼 — + * TS 를 그 자리에서 트랜스파일해 Node 로 돌리고 결과를 JSON 으로 냄(pytest 가 판정). + * 정의는 진짜 `구조물정의.json` 을 읽어 메모리 사본만 고침(파일 쓰기 없음). */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const load = (...parts) => { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, ...parts), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, require); + return box.exports; +}; +const m = load("M02_MasterTemplete", "StructOptions", "M02_SO_Def_Spec_Model.ts"); +const { PIPE_SPEC } = load("A00_Common", "design", "design_structure_pipe.ts"); +const real = () => + JSON.parse( + fs.readFileSync(path.join(ROOT, "resources", "master_template", "구조물정의.json"), "utf8"), + ); + +const out = {}; +const def = real(); +const rows = m.specNameRows(def, PIPE_SPEC); +out.levels = [0, 1, 2].map((lv) => rows.filter((r) => r.level === lv).length); +out.unique = new Set(rows.map((r) => r.nameKey)).size === rows.length; +out.pipeTitle = rows.find((r) => r.nameKey === "pipe"); +out.optional = rows.find((r) => r.nameKey === "soil_add"); +out.basinForm = rows.find((r) => r.nameKey === "inlet.form=I"); + +// 이름 고침 — 그룹 제목 · 항목 · 값 · 선택 컨테이너 +const work = real(); +const pick = (k) => m.specNameRows(work, PIPE_SPEC).find((r) => r.nameKey === k); +m.setSpecName(work, "pipe", pick("pipe"), "관 본체"); +m.setSpecName(work, "pipe", pick("pipe.diameter"), "관 지름"); +m.setSpecName(work, "pipe", pick("pipe.diameter=800"), "Ø800"); +m.setSpecName(work, "pipe", pick("soil_add"), "흙막이 더"); +out.groupName = work.그룹.pipe.find((g) => g.id === "grv6lny").이름; +out.names = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map( + (k) => work.종류.pipe.이름[k] ?? null, +); +out.reread = ["pipe", "pipe.diameter", "pipe.diameter=800", "soil_add"].map((k) => pick(k).label); +// 구조는 그대로 — types · 동작 · 다른 종류 · 이름 표 키 수(새 키 없음) +const before = real(); +out.sameTypes = JSON.stringify(work.types) === JSON.stringify(before.types); +out.sameKeys = + Object.keys(work.종류.pipe.이름).length === Object.keys(before.종류.pipe.이름).length; + +// 이름 표가 빈 종류 — 명세 글로 읽고 고치면 이름 표가 생김 +const bare = real(); +delete bare.종류.pipe.이름; +const bareRows = m.specNameRows(bare, PIPE_SPEC); +out.bareLabel = bareRows.find((r) => r.nameKey === "inlet.structure=기슭막이").label; +m.setSpecName( + bare, + "pipe", + bareRows.find((r) => r.nameKey === "inlet"), + "유입", +); +out.bareGroup = bare.그룹.pipe.find((g) => g.id === "gnbf9m9").이름; + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_so_def_spec.py b/resources/tester/test_m02_so_def_spec.py new file mode 100644 index 000000000..3677816ff --- /dev/null +++ b/resources/tester/test_m02_so_def_spec.py @@ -0,0 +1,58 @@ +"""M02 구조물 명세 이름 고치기(`M02_SO_Def_Spec_Model.ts` · PLAN 52-11) 단위검증. + +Node 헬퍼(`helper_m02_so_def_spec.cjs`)가 TS 를 트랜스파일해 진짜 정의 사본(메모리)으로 돌리고 +pytest 는 그 결과(JSON)를 판정. + +못박는 것 + - 배관 명세 나무: 컨테이너 5 · 항목 · 값 줄 · 이름 키 겹침 없음 · 필수/선택 · 꼴 표시. + - 필수 컨테이너 제목은 정의 그룹 이름에 씀 · 항목 · 값 · 선택 컨테이너는 이름 표에 씀. + - 이름만 바뀜 — types(옵션 · 키) 그대로 · 이름 표에 없는 키를 새로 만들지 않음. + - 이름 표가 빈 종류는 명세 글로 읽음. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( + ["node", str(HERE / "helper_m02_so_def_spec.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_배관_나무(r) -> None: + assert r["levels"][0] == 5 + assert r["levels"][1] > 5 and r["levels"][2] > 10 + assert r["unique"] is True + assert r["pipeTitle"]["tag"] == "required" and r["pipeTitle"]["group"] == "grv6lny" + assert r["optional"]["tag"] == "optional" and r["optional"]["group"] is None + assert r["basinForm"]["label"] == "I형" and r["basinForm"]["key"] == "I" + + +def test_이름_고침_자리(r) -> None: + assert r["groupName"] == "관 본체" + assert r["names"] == [None, "관 지름", "Ø800", "흙막이 더"] + assert r["reread"] == ["관 본체", "관 지름", "Ø800", "흙막이 더"] + + +def test_구조는_그대로(r) -> None: + assert r["sameTypes"] is True and r["sameKeys"] is True + + +def test_이름_표_없는_종류(r) -> None: + assert r["bareLabel"] == "흙막이" + assert r["bareGroup"] == "유입" diff --git a/resources/tester/ui_checks/52-11_정의_이름고치기.js b/resources/tester/ui_checks/52-11_정의_이름고치기.js new file mode 100644 index 000000000..6d4679afe --- /dev/null +++ b/resources/tester/ui_checks/52-11_정의_이름고치기.js @@ -0,0 +1,155 @@ +/* 52-11 — M02 「구조물 옵션 정의」 = 구조물 명세 이름 고치기(명세 있는 종류) · B05 가 그 이름으로 그림 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5211 = "단계";` 를 붙여 + * M02 마스터 층(#/m02-master-template?root=master) · 시스템 관리자: + * orca eval --expression "window.__m5211='m02'; $(cat 이 파일)" + * B05 종단설계(#/b05-profile) · 목록에 「배관 1」: + * orca eval --expression "window.__m5211='b05'; $(cat 이 파일)" + * 쓰는 것: 없음 — M02 는 화면 사본만 고치고 [저장] 안 누름 · 되돌린 뒤 판 그대로를 GET 으로 봄 · + * B05 는 받아 둔 종류 객체(메모리)만 고침 · 끝나면 orca reload 로 사본을 버림 + * 기대 수치(m02): + * - spec = 배관 나무 줄(컨테이너 5) · old = 옛 편집기 0(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) · other = 명세 없는 종류는 옛 편집기 + * - 이름 칸만 고침: inputs = 줄 수 · 키 · 꼴은 글(입력 칸 아님) + * - 컨테이너 제목 · 항목 이름 · 값 이름 고침 → 미리보기 legend · 칸 이름 · 선택지 글 따라감 · [저장] 켜짐 + * - 빈 이름 → issues 「명세 이름」 · [저장] 꺼짐 · 되돌리면 이름 그대로 · 판 그대로 + * 기대 수치(b05): 사본 이름(컨테이너 · 항목 · 값)이 배관 폼에 그대로 → named.* = true + */ +(async () => { + const MODE = window.__m5211 || "m02"; + const W = (ms) => new Promise((r) => setTimeout(r, ms)); + const $ = (s, root = document) => root.querySelector(s); + const $$ = (s, root = document) => [...root.querySelectorAll(s)]; + const until = async (fn, ms = 10000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await W(150); + } + return null; + }; + const put = (box, value) => { + box.value = value; + box.dispatchEvent(new Event("input", { bubbles: true })); + box.dispatchEvent(new Event("change", { bubbles: true })); + }; + const NEW = { pipe: "관 본체X", "pipe.diameter": "관 지름X", "pipe.diameter=800": "Ø800X" }; + const out = { mode: MODE }; + + if (MODE === "m02") { + const getDef = async () => + (await fetch("/api/m02/structure-definition", { credentials: "include" })).json(); + const first = await getDef(); + out.판 = first.판; + const row = await until(() => $("[data-options-row]")); + if (!row) return JSON.stringify({ error: "정의 줄 없음" }); + row.click(); + if (!(await until(() => $$("[data-sod-kind]").length))) + return JSON.stringify({ error: "종류 목록 없음" }); + // 명세 없는 종류 — 옛 편집기 그대로 + $('[data-sod-kind="retaining_wall"]').click(); + await W(200); + out.other = { base: !!$("[data-sod-base]"), spec: !!$("[data-sod-spec-block]") }; + // 배관 — 명세 이름 나무 + $('[data-sod-kind="pipe"]').click(); + await W(200); + const block = $("[data-sod-spec-block]"); + const detail = $("[data-sod-detail]"); + const rows = $$("[data-sod-spec]", block); + out.spec = { + rows: rows.length, + containers: $$(".is-level-0", block).length, + inputs: $$("input, select, textarea", block).filter( + (n) => !n.closest("[data-sod-spec-preview]"), + ).length, + nameInputs: $$("[data-sod-spec-name]", block).length, + keysReadOnly: rows.every((r) => r.querySelector("small.m02-opts__key")), + }; + out.old = { + base: !!$("[data-sod-base]", detail), + blocks: detail.children.length, // 머리 칸 + 명세 칸 = 2 + }; + const save = $("[data-sod-save]"); + out.saveBefore = save.disabled; + const box = (k) => $(`[data-sod-spec="${k}"] [data-sod-spec-name]`); + const orig = Object.fromEntries(Object.keys(NEW).map((k) => [k, box(k).value])); + for (const [k, v] of Object.entries(NEW)) { + put(box(k), v); + await W(150); + } + const preview = $("[data-sod-spec-preview]"); + const pipeBox = $('[data-spec-container="pipe"]', preview); + out.preview = { + title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe), + item: pipeBox + .querySelector('[data-spec-item="pipe.diameter"]') + .textContent.includes(NEW["pipe.diameter"]), + choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some( + (o) => o.textContent === NEW["pipe.diameter=800"], + ), + }; + out.saveAfterEdit = $("[data-sod-save]").disabled; + // 빈 이름 → 저장 막힘 + put(box("pipe.diameter"), ""); + await W(150); + out.blank = { + issue: $("[data-sod-issues]").textContent.includes("명세 이름"), + save: $("[data-sod-save]").disabled, + }; + // 되돌림 + for (const [k, v] of Object.entries(orig)) { + put(box(k), v); + await W(150); + } + out.restored = Object.entries(orig).every(([k, v]) => box(k).value === v); + out.판After = (await getDef()).판; + out.ok = + out.other.base && + !out.other.spec && + out.spec.containers === 5 && + out.spec.inputs === out.spec.rows && + out.spec.nameInputs === out.spec.rows && + out.spec.keysReadOnly && + !out.old.base && + out.old.blocks === 2 && + out.preview.title && + out.preview.item && + out.preview.choice && + !out.saveAfterEdit && + out.blank.issue && + out.blank.save && + out.restored && + out.판After === out.판; + } else { + const sec = $(".b05-structure-section"); + if (!sec) return JSON.stringify({ error: "구조물 배치 칸 없음" }); + const url = performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes("/B05_Profile/B05_Profile_Api_Structures.ts")) + .pop(); + const api = await import(url); + const pipe = (await api.fetchStructureTypes(true)).find((t) => t.type_id === "pipe"); + pipe.spec_names = { ...(pipe.spec_names ?? {}), ...NEW }; + await until(() => $$(".b05-route__irregular-list li").length); + const li = $$(".b05-route__irregular-list li").find((l) => + /배관 1$/.test(l.textContent.trim()), + ); + const spec = () => $(".b05-structure__spec", sec); + (li.querySelector("button") ?? li).click(); + await W(800); + if (!(spec() && spec().offsetParent !== null)) (li.querySelector("button") ?? li).click(); + await until(() => spec() && spec().offsetParent !== null); + const pipeBox = $('[data-spec-container="pipe"]', spec()); + out.named = { + title: pipeBox.querySelector("legend").textContent.includes(NEW.pipe), + item: pipeBox + .querySelector('[data-spec-item="pipe.diameter"]') + .textContent.includes(NEW["pipe.diameter"]), + choice: [...pipeBox.querySelectorAll('[data-spec-item="pipe.diameter"] option')].some( + (o) => o.textContent === NEW["pipe.diameter=800"], + ), + }; + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + out.ok = Object.values(out.named).every(Boolean); + } + return JSON.stringify(out); +})(); From 532c20085cd5fcea7805e63036033ee87082160b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:43:05 +0900 Subject: [PATCH 05/37] =?UTF-8?q?feat(design):=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=B0=B0=EC=88=98=20=EC=84=B8=EC=9B=94=EA=B5=90(=ED=91=9C=2004?= =?UTF-8?q?)=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=ED=8F=BC=20=C2=B7?= =?UTF-8?q?=20=EC=9B=94=EB=A5=98=20=ED=8F=AD=20=E2=86=94=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 세월교 명세(design_structure_ford) — 세월교(관경 · 월류 폭 · 월류 높이 · 수량(련)) · 유입 구조 · 유출 구조(없음 · 날개벽 · 흙막이) · 모두 필수 · 컨테이너마다 구조물 조합 잇기(gford · gfordin · gfordout) - 구조 키 inlet_type · outlet_type + 옛 날개벽 설치 칸(wing_in · wing_out) 함께 씀 · 옛 저장 · B06 조정창 설치 칸으로 구조를 맞춤 · 옛 관종 · 날개벽 치수는 지난 정의 값 - 계산 파일 따로(design_structure_ford_calc) — 높이 = 설계유량을 그 폭으로 넘기는 Manning 필요 수심 · 0.01 올림 · 폭을 바꾸면 채움 · 그 아래는 안 받음 · 근거 통수단면.md:23~33 · :59 - B06 날개벽 조정 행 자리 = 유입 · 유출 구조 컨테이너 끝 · 세월교 옛 관종 · 관경 · 월류 칸 걷음 - 정의 종류.ford_bridge.이름 표 · 명세 등록(STRUCTURE_SPECS) · 52-1 시험 헬퍼가 같은 폴더 명세 import 를 읽음 - 시험 test_52_4_structure_spec_ford · 화면 시나리오 ui_checks/52-4_세월교_컨테이너 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- A00_Common/design/design_structure_ford.ts | 88 +++++++++ .../design/design_structure_ford_calc.ts | 27 +++ A00_Common/design/design_structure_pipe.ts | 8 +- .../B05_Profile_UI_Drainage_Facility.ts | 124 ++++++------ .../B05_Profile_UI_Drainage_Facility_Merge.ts | 8 +- resources/master_template/구조물정의.json | 20 +- resources/tester/helper_52_4_ford.cjs | 86 +++++++++ resources/tester/helper_52_structure_spec.cjs | 3 +- .../tester/test_52_4_structure_spec_ford.py | 109 +++++++++++ .../tester/ui_checks/52-4_세월교_컨테이너.js | 181 ++++++++++++++++++ 10 files changed, 590 insertions(+), 64 deletions(-) create mode 100644 A00_Common/design/design_structure_ford.ts create mode 100644 A00_Common/design/design_structure_ford_calc.ts create mode 100644 resources/tester/helper_52_4_ford.cjs create mode 100644 resources/tester/test_52_4_structure_spec_ford.py create mode 100644 resources/tester/ui_checks/52-4_세월교_컨테이너.js diff --git a/A00_Common/design/design_structure_ford.ts b/A00_Common/design/design_structure_ford.ts new file mode 100644 index 000000000..55bb897e0 --- /dev/null +++ b/A00_Common/design/design_structure_ford.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * design_structure_ford.ts + * 횡단배수 › 세월교 명세 — 사용자 구조물 표 04(`docs/raw/구조물정의_표/04_횡단배수_세월교.md`) 그대로. + * + * 컨테이너 셋: 세월교(필수) · 유입 구조(필수) · 유출 구조(필수). 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. + * 저장 키 = pipe_points.json 옛 키 그대로(B06 세월교 세트 · B07 · B08 이 읽는 자리) — 새 키 = 유입 · 유출 구조 + * (`inlet_type` · `outlet_type` = 없음 · 날개벽 · 흙막이) · 고르면 옛 날개벽 설치 칸(`wing_in` · `wing_out`)도 함께 적음. + * 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래로는 안 받음) — 계산은 `design_structure_ford_calc`. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecContainer, SpecChoice, StructureSpec } from "./design_structure_spec"; + +/** 유입 · 유출 구조 — 날개벽만 옛 설치 칸이 「있음」. */ +const guard = (side: "in" | "out"): SpecChoice[] => [ + { value: "없음", label: "없음", also: { [`wing_${side}`]: "없음" } }, + { value: "날개벽", label: "날개벽", also: { [`wing_${side}`]: "있음" } }, + { value: "흙막이", label: "흙막이", also: { [`wing_${side}`]: "없음" } }, +]; + +const guardContainer = (id: "inlet" | "outlet", title: string, link: string): SpecContainer => ({ + id, + title, + required: true, + link, + items: [ + { + id: "structure", + label: "구조", + kind: "select", + key: `${id}_type`, + choices: guard(id === "inlet" ? "in" : "out"), + }, + ], +}); + +export const FORD_SPEC: StructureSpec = { + typeId: "ford_bridge", + containers: [ + { + id: "ford", + title: "세월교", + required: true, + link: "gford", + items: [ + { + id: "diameter", + label: "관경", + kind: "select", + key: "pipe_diameter_mm", + asNumber: true, + choices: ["800", "1000", "1200", "1500"].map((v) => ({ value: v, label: v })), + }, + { id: "width", label: "월류 폭 (m)", kind: "number", key: "ford_width_m", step: 0.1 }, + { id: "height", label: "월류 높이 (m)", kind: "number", key: "ford_height_m", step: 0.01 }, + { id: "count", label: "수량 (련)", kind: "number", key: "pipe_count", step: 1 }, + ], + }, + guardContainer("inlet", "유입 구조", "gfordin"), + guardContainer("outlet", "유출 구조", "gfordout"), + ], + // 표 04 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: [ + "pipe_kind", + "wing_in_height_m", + "wing_in_length_m", + "wing_in_angle_deg", + "wing_out_height_m", + "wing_out_length_m", + "wing_out_angle_deg", + ], +}; + +/** 구조를 날개벽 설치 칸(옛 저장 · B06 조정창이 고치는 칸)에 맞춤 — 있음 = 날개벽 · 없음 = 날개벽이면 없음 + * (흙막이는 그대로) · 설치 칸이 없으면 구조 그대로. */ +export function withLegacyGuards( + options: Record, +): Record { + const out = { ...options }; + for (const [type, wing] of [ + ["inlet_type", "wing_in"], + ["outlet_type", "wing_out"], + ]) { + if (out[wing] === "있음") out[type] = "날개벽"; + else if (out[wing] === "없음" && (out[type] ?? "날개벽") === "날개벽") out[type] = "없음"; + } + return out; +} diff --git a/A00_Common/design/design_structure_ford_calc.ts b/A00_Common/design/design_structure_ford_calc.ts new file mode 100644 index 000000000..a9ec865a0 --- /dev/null +++ b/A00_Common/design/design_structure_ford_calc.ts @@ -0,0 +1,27 @@ +/* ============================================================================= + * design_structure_ford_calc.ts + * 세월교 명세 계산 — 월류 폭 ↔ 월류 높이 연동(PLAN 52-4). + * + * 높이 = 담당 유역 설계유량을 그 월류 폭으로 넘기는 필요 수심(Manning 개수로 · `fordSection` 그대로). + * 근거: Manning 공식 `resources/knowledge/technical_info/01_임도/03_계산정보/통수단면.md:23~33` + * (KDS 44 40 00 2.7.1~2.7.2) · 조도계수 0.017 = 같은 파일 :59(콘크리트 수로 보통 · 울진 물넘이) · + * 경사 10 % = 실무 수리계산서 역산값(`config_frontend.FORD_SLOPE` · 지식DB 수치 없음). + * 폭을 바꾸면 높이 칸을 이 값으로 채움 · 사용자는 올려 적을 수 있고 이 값 아래로는 안 받음. + * 높이는 저장되는 입력값 — 서버는 받은 높이로 노면을 내림(`B06_Section_Engine_Culvert._ford_set`) · 재계산 안 함. + * 설계유량이 없으면 null = 손 입력. + * ========================================================================== */ + +import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; + +/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */ +export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null { + const section = fordSection(designFlowM3s, widthM); + return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null; +} + +/** 높이 빈 칸 안내. */ +export function fordHeightHint(designFlowM3s: number | null, widthM: number): string { + const need = fordHeightM(designFlowM3s, widthM); + if (need !== null) return `필요 수심 ${need.toFixed(2)} 이상`; + return designFlowM3s ? "월류 폭을 넣으면 셈" : "설계유량 없음 — 손 입력"; +} diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index c72f84c0b..ffc019894 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -8,10 +8,11 @@ * (`inlet_type` · `*_revet_*`)에 둠. 새 키 = 집수정 높이 · 선택 컨테이너(`soil_add_{n}_*` · `chute_add_{n}_*`). * 정함(PLAN 52): 유출 형태 = 흙막이 6가지 · 도수로/돌붙임 = 구조만 · 길이 = 전 + 후 맞물림. * 계산(관 길이 · 높이 안내)은 `design_structure_pipe_calc`. - * import 없음 — 시험이 이 파일만 옮겨 돌림. + * import = 같은 폴더 명세뿐 — 시험이 이 폴더 파일만 옮겨 돌림. * ========================================================================== */ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; +import { FORD_SPEC } from "./design_structure_ford"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -184,4 +185,7 @@ export const PIPE_SPEC: StructureSpec = { }; /** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */ -export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC }; +export const STRUCTURE_SPECS: Readonly> = { + pipe: PIPE_SPEC, + ford_bridge: FORD_SPEC, +}; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index e7371d120..3b540ee46 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -53,6 +53,8 @@ import { } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; +import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structure_ford"; +import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; import { guardHeightHint, pipeLengthM } from "../A00_Common/design/design_structure_pipe_calc"; import { hasLinks, @@ -250,45 +252,31 @@ export function createFacilityOptionsForm( root.className = "b05-drainage__facility"; root.hidden = true; - // ── 관종 · 관경 한 행 — 세월교 몫(배관은 아래 명세 폼 · PLAN 52-1) ───────── - const pipeMaterial = optionalSelect("— 안 정함 —", ["흄관", "VR관", "파형강관"]); - const pipeDiameter = document.createElement("select"); - pipeDiameter.replaceChildren( - new Option("— 안 정함 —", ""), - ...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)), - ); - const pipeRow = grid( - labeled("관종", pipeMaterial, "pipe_kind"), - labeled("관경", pipeDiameter, "pipe_diameter_mm"), - ); - // ── 배관 = 사용자 구조물 표 01 명세(PLAN 52-1) — 컨테이너 다섯 · 옛 관종 · 날개벽 칸은 뺌(지난 정의 값) ── let linkSource: SpecLinks | null = null; let chainage: number | null = null; - const pipeForm = createSpecForm( - { - get: () => linkSource?.get() ?? {}, - pick: (key) => linkSource?.pick(key), - clear: (key) => linkSource?.clear(key), - set: (key, item) => linkSource?.set(key, item), + const specLinks: SpecLinks = { + get: () => linkSource?.get() ?? {}, + pick: (key) => linkSource?.pick(key), + clear: (key) => linkSource?.clear(key), + set: (key, item) => linkSource?.set(key, item), + }; + const pipeForm = createSpecForm(specLinks, { + auto: (_c, item) => { + if (item.id !== "length") return ""; + const length = pipeLengthM(chainage); + return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); }, - { - auto: (_c, item) => { - if (item.id !== "length") return ""; - const length = pipeLengthM(chainage); - return length === null ? "횡단 설계 뒤 셈" : length.toFixed(2); - }, - hint: (c, item, state) => { - const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; - return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; - }, - onChange: (c, item) => { - if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) - inletStructureHandler?.(inletStructureOf()); - emit(); - }, + hint: (c, item, state) => { + const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; + return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; }, - ); + onChange: (c, item) => { + if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) + inletStructureHandler?.(inletStructureOf()); + emit(); + }, + }); // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4) const inletSlot = pipeForm.slot("inlet"); const outletSlot = pipeForm.slot("outlet"); @@ -301,6 +289,26 @@ export function createFacilityOptionsForm( : "auto"; }; + // ── 세월교 = 사용자 구조물 표 04 명세(PLAN 52-4) — 세월교 · 유입 구조 · 유출 구조 ── + // 월류 높이 = 월류 폭과 연동(폭을 바꾸면 필요 수심으로 채움 · 그 아래는 안 받음 · `design_structure_ford_calc`) + let fordFlow: number | null = null; + const fordNeed = (): number | null => + fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? "")); + const fordForm = createSpecForm(specLinks, { + hint: (c, item, state) => + c.id === "ford" && item.id === "height" + ? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? "")) + : "", + onChange: (c, item) => { + const need = c.id === "ford" ? fordNeed() : null; + const height = fordForm.state().ford?.[0]?.height ?? ""; + const low = height !== "" && !(Number(height) >= (need ?? 0)); + if (need !== null && (item.id === "width" || (item.id === "height" && low))) + fordForm.setValue("ford", "height", need.toFixed(2)); + emit(); + }, + }); + // ── 추가 기슭막이(다단) — 횡단도에서 다단 벽을 고를 때만 뜬다(2026-08-30 사용자 // 지시 4). 유입구·유출구와 같은 양식이되, 값은 조정창 사본의 행이 그대로 옮겨 온다 // (다단 벽은 폼에 자기 칸이 없다 — 높이·형태·구간값이 전부 조정 채널에 있다). @@ -370,6 +378,8 @@ export function createFacilityOptionsForm( ); const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); + const wingInHome = wingInFields.slot.parentElement!; + const wingOutHome = wingOutFields.slot.parentElement!; const hiddenOptions = createHiddenOptions(root); @@ -399,8 +409,8 @@ export function createFacilityOptionsForm( // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( saveNote, - pipeRow, pipeForm.root, + fordForm.root, ford.widthRow, ford.slopeRow, ford.countRow, @@ -421,15 +431,18 @@ export function createFacilityOptionsForm( if (current === null) return; const isPipe = current === "pipe"; const isBox = current === "box_culvert"; - pipeRow.hidden = current !== "ford_bridge"; + const isBridge = current === "ford_bridge"; pipeForm.root.hidden = !isPipe; + fordForm.root.hidden = !isBridge; boxWrap.hidden = !isBox; - // 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정). - const hasWing = isBox || current === "ford_bridge"; + // 날개벽 칸 = BOX암거 · 세월교는 유입 · 유출 구조 컨테이너(52-4) — B06 조정 행 자리만 그 컨테이너 끝으로 + const hasWing = isBox; wingInFields.root.hidden = !hasWing; wingOutFields.root.hidden = !hasWing; - const isFord = current === "ford_pavement" || current === "ford_bridge"; - ford.countRow.hidden = current !== "ford_bridge"; + (isBridge ? fordForm.slot("inlet") : wingInHome).append(wingInFields.slot); + (isBridge ? fordForm.slot("outlet") : wingOutHome).append(wingOutFields.slot); + const isFord = current === "ford_pavement"; + ford.countRow.hidden = true; ford.widthRow.hidden = !isFord; // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. ford.slopeRow.hidden = current !== "ford_pavement"; @@ -462,8 +475,6 @@ export function createFacilityOptionsForm( revetOutlet.fields.onChange(emit); revetSide.addEventListener("change", emit); [ - pipeMaterial, - pipeDiameter, boxSize, boxWidth, boxHeight, @@ -500,8 +511,12 @@ export function createFacilityOptionsForm( bindLinks(links) { linkSource = links; pipeForm.paintLinks(); + fordForm.paintLinks(); + }, + paintLinks: () => { + pipeForm.paintLinks(); + fordForm.paintLinks(); }, - paintLinks: () => pipeForm.paintLinks(), setChainage(chainageM) { chainage = chainageM; if (current === "pipe") pipeForm.refresh(); @@ -516,6 +531,7 @@ export function createFacilityOptionsForm( current = facility; hiddenOptions.remember(options); ford.setDesignFlow(designFlow ?? null); + fordFlow = designFlow ?? null; if (facility === null) { syncVisibility(); return; @@ -523,12 +539,11 @@ export function createFacilityOptionsForm( const text = (key: string): string => options[key] !== undefined ? String(options[key]) : ""; // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). - pipeMaterial.value = text("pipe_kind"); - pipeDiameter.value = text("pipe_diameter_mm"); - if (facility === "pipe") { - const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); + const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); + if (facility === "pipe") pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups); - } + if (facility === "ford_bridge") + fordForm.write(FORD_SPEC, withLegacyGuards(options), definition?.spec_names ?? {}, groups); // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. // 값이 없으면 「안 정함」. const savedWidth = Number.parseFloat(text("body_width_m")); @@ -549,7 +564,7 @@ export function createFacilityOptionsForm( boxHeight.value = hasSaved ? String(savedHeight) : ""; wingInFields.write(options); wingOutFields.write(options); - if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options); + if (facility === "ford_pavement") ford.write(facility, options); revetSide.value = text("side"); const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); @@ -558,15 +573,13 @@ export function createFacilityOptionsForm( }, setDesignFlow(designFlow) { ford.setDesignFlow(designFlow ?? null); + fordFlow = designFlow ?? null; + if (current === "ford_bridge") fordForm.refresh(); // 높이 안내 syncVisibility(); }, readOptions() { const options: Record = {}; // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). - const putPipe = (): void => { - if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value); - if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value; - }; if (current === "pipe") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) } else if (current === "box_culvert") { @@ -578,10 +591,7 @@ export function createFacilityOptionsForm( } else if (current === "ford_pavement") { ford.read(current, options); } else if (current === "ford_bridge") { - putPipe(); - ford.read(current, options); - wingInFields.read(options); - wingOutFields.read(options); + Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4) } else if (current === "revetment") { if (revetSide.value) options.side = revetSide.value; // 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 4e6afd657..51b56df66 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -42,14 +42,16 @@ export const FORM_OPTION_KEYS: Readonly> = { ], box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")], ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], + // 세월교 = 표 04 명세 키(PLAN 52-4 · `design_structure_ford`) — 옛 관종 · 날개벽 치수는 폼에서 빠져 이어 붙임 ford_bridge: [ - "pipe_kind", "pipe_diameter_mm", "pipe_count", "ford_width_m", "ford_height_m", - ...wingKeys("in"), - ...wingKeys("out"), + "inlet_type", + "outlet_type", + "wing_in", + "wing_out", ], revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")], }; diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 5b2e2bf07..dedd4bf76 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -3308,7 +3308,25 @@ "계곡통과시설", "월류단면", "날개벽" - ] + ], + "이름": { + "ford.diameter": "관경", + "ford.diameter=800": "800", + "ford.diameter=1000": "1000", + "ford.diameter=1200": "1200", + "ford.diameter=1500": "1500", + "ford.width": "월류 폭 (m)", + "ford.height": "월류 높이 (m)", + "ford.count": "수량 (련)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이" + } }, "revetment": { "동작": [ diff --git a/resources/tester/helper_52_4_ford.cjs b/resources/tester/helper_52_4_ford.cjs new file mode 100644 index 000000000..b11d13b4c --- /dev/null +++ b/resources/tester/helper_52_4_ford.cjs @@ -0,0 +1,86 @@ +/* 세월교 명세(표 04) · 월류 폭 ↔ 높이 계산 단위검증 헬퍼(PLAN 52-4) — `design_structure_spec.ts` · + * `design_structure_ford.ts` · `design_structure_ford_calc.ts`(+ `fordSection` 이 든 B05 칸 조각)를 그 자리에서 + * 트랜스파일해 Node 로 돌림. 결과 = JSON 한 벌. 인자 `names` 면 세월교 이름 표(정의 `종류.ford_bridge.이름`)만 냄. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); +/** TS 한 파일을 CommonJS 로 — 상대 import 는 그 파일 자리 기준 · `@config/` 는 config 폴더 · .json 은 그대로. */ +const load = (file) => { + if (cache.has(file)) return cache.get(file); + if (file.endsWith(".json")) return JSON.parse(fs.readFileSync(file, "utf8")); + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(file, box.exports); + const near = (m) => { + const base = m.startsWith("@config/") + ? path.join(ROOT, "config", m.slice(8)) + : path.join(path.dirname(file), m); + return load(base.endsWith(".json") ? base : `${base}.ts`); + }; + new Function("exports", "module", "require", js)(box.exports, box, near); + cache.set(file, box.exports); + return box.exports; +}; +const D = (f) => path.join(ROOT, "A00_Common", "design", f); +const s = load(D("design_structure_spec.ts")); +const { FORD_SPEC, withLegacyGuards } = load(D("design_structure_ford.ts")); +const calc = load(D("design_structure_ford_calc.ts")); + +if (process.argv[2] === "names") { + process.stdout.write(JSON.stringify(s.nameTable(FORD_SPEC))); + process.exit(0); +} + +const old = { + pipe_kind: "파형강관", + pipe_diameter_mm: 1000, + pipe_count: 2, + ford_width_m: 10, + ford_height_m: 0.3, + wing_in: "있음", + wing_in_height_m: 1, + wing_out: "없음", +}; +const legacy = withLegacyGuards(old); +const state = s.readState(FORD_SPEC, legacy); +const back = s.writeOptions(FORD_SPEC, state); +const soil = JSON.parse(JSON.stringify(state)); +soil.inlet[0].structure = "흙막이"; +soil.outlet[0].structure = "날개벽"; +const soilOut = s.writeOptions(FORD_SPEC, soil); + +const flows = [0.5, 2.54, 8]; +const widths = [3, 5, 10, 20]; +const table = flows.flatMap((q) => widths.map((b) => [q, b, calc.fordHeightM(q, b)])); + +const out = { + containers: FORD_SPEC.containers.map((c) => [c.id, c.title, c.required]), + items: FORD_SPEC.containers.map((c) => c.items.map((i) => [i.id, i.kind, i.key ?? null])), + choices: FORD_SPEC.containers.map((c) => + c.items.filter((i) => i.kind === "select").map((i) => s.choicesOf(i, {}).map((ch) => ch.label)), + ), + legacy: { inlet: legacy.inlet_type, outlet: legacy.outlet_type }, + keepSoil: withLegacyGuards({ inlet_type: "흙막이", wing_in: "없음" }).inlet_type, + b06Off: withLegacyGuards({ inlet_type: "날개벽", wing_in: "없음" }).inlet_type, + b06On: withLegacyGuards({ inlet_type: "없음", wing_in: "있음" }).inlet_type, + state, + back, + soilOut, + links: FORD_SPEC.containers.map((c) => s.linkOf(c, 1)), + keys: s.specKeys(FORD_SPEC), + retired: FORD_SPEC.retired, + table, + none: [calc.fordHeightM(null, 10), calc.fordHeightM(2.54, 0)], + hints: [ + calc.fordHeightHint(2.54, 10), + calc.fordHeightHint(2.54, 0), + calc.fordHeightHint(null, 10), + ], +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index 0ff82723b..168721216 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -13,7 +13,8 @@ const load = (file, deps = {}) => { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; const box = { exports: {} }; - new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? require(m)); + const near = (m) => (m.startsWith("./") ? load(`${m.slice(2)}.ts`) : require(m)); // 같은 폴더 명세 + new Function("exports", "module", "require", js)(box.exports, box, (m) => deps[m] ?? near(m)); return box.exports; }; const s = load("design_structure_spec.ts"); diff --git a/resources/tester/test_52_4_structure_spec_ford.py b/resources/tester/test_52_4_structure_spec_ford.py new file mode 100644 index 000000000..90a973a9b --- /dev/null +++ b/resources/tester/test_52_4_structure_spec_ford.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +"""횡단배수 › 세월교(표 04) 명세 + 월류 폭 ↔ 높이 연동 — PLAN 52-4. + +순수 규칙은 Node 헬퍼(`helper_52_4_ford.cjs`)가 돌림 · 화면은 ORCA `ui_checks/52-4_세월교_컨테이너.js`. +높이 거울 = 이 파일이 Manning(통수단면.md §2 · n 0.017 · S 0.10)을 따로 풀어 같은 값인지 봄. +""" + +import json +import math +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +N, S = 0.017, 0.10 + + +def _node(*args): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_4_ford.cjs"), *args], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def out(): + return _node() + + +def test_컨테이너_셋_모두_필수(out): + assert out["containers"] == [ + ["ford", "세월교", True], + ["inlet", "유입 구조", True], + ["outlet", "유출 구조", True], + ] + assert out["items"][0] == [ + ["diameter", "select", "pipe_diameter_mm"], + ["width", "number", "ford_width_m"], + ["height", "number", "ford_height_m"], + ["count", "number", "pipe_count"], + ] + assert out["choices"] == [ + [["800", "1000", "1200", "1500"]], + [["없음", "날개벽", "흙막이"]], + [["없음", "날개벽", "흙막이"]], + ] + + +def test_옛_날개벽_칸으로_구조를_세우고_같은_키로_씀(out): + assert out["legacy"] == {"inlet": "날개벽", "outlet": "없음"} + assert out["keepSoil"] == "흙막이" # 흙막이는 설치 칸 없음이어도 그대로 + assert out["b06Off"] == "없음" and out["b06On"] == "날개벽" # B06 조정창 설치 칸을 따름 + back = out["back"] + assert back["pipe_diameter_mm"] == 1000 and back["pipe_count"] == 2 + assert back["ford_width_m"] == 10 and back["ford_height_m"] == 0.3 + assert back["wing_in"] == "있음" and back["wing_out"] == "없음" + # 표 04 에 없는 옛 칸은 폼이 안 적음(병합이 이어 붙임 = 지난 정의 값) + assert "pipe_kind" not in back and "wing_in_height_m" not in back + assert "pipe_kind" in out["retired"] + + +def test_흙막이_날개벽_고르면_옛_설치_칸도_맞춰_씀(out): + s = out["soilOut"] + assert s["inlet_type"] == "흙막이" and s["wing_in"] == "없음" + assert s["outlet_type"] == "날개벽" and s["wing_out"] == "있음" + + +def test_잇기_키_컨테이너마다(out): + assert out["links"] == ["gford", "gfordin", "gfordout"] + + +def _manning_depth(q, b): + """넓은 직사각 수로 Manning 을 h 에 대해 이분법으로 풂 — 헬퍼와 다른 풀이.""" + + def cap(h): + return b * h * (1 / N) * ((b * h) / (b + 2 * h)) ** (2 / 3) * math.sqrt(S) + + lo, hi = 0.0, 5.0 + for _ in range(100): + mid = (lo + hi) / 2 + lo, hi = (mid, hi) if cap(mid) < q else (lo, mid) + return hi + + +def test_월류_높이는_폭과_연동_필요_수심_올림(out): + for q, b, h in out["table"]: + need = _manning_depth(q, b) + assert h == pytest.approx(math.ceil(need * 100 - 1e-9) / 100), (q, b) + # 폭이 넓을수록 높이가 낮아짐 + row = [h for q, b, h in out["table"] if q == 2.54] + assert row == sorted(row, reverse=True) and len(set(row)) == len(row) + assert out["none"] == [None, None] # 설계유량 · 폭이 없으면 손 입력 + assert out["hints"] == ["필요 수심 0.08 이상", "월류 폭을 넣으면 셈", "설계유량 없음 — 손 입력"] + + +def test_정의_파일에_세월교_이름_표가_올라_있음(): + names = _node("names") + with open( + os.path.join(ROOT, "resources", "master_template", "구조물정의.json"), encoding="utf-8" + ) as f: + doc = json.load(f) + assert doc["종류"]["ford_bridge"]["이름"] == names diff --git a/resources/tester/ui_checks/52-4_세월교_컨테이너.js b/resources/tester/ui_checks/52-4_세월교_컨테이너.js new file mode 100644 index 000000000..c5c9143e0 --- /dev/null +++ b/resources/tester/ui_checks/52-4_세월교_컨테이너.js @@ -0,0 +1,181 @@ +/* 52-4 — B05 · B06 좌측 「구조물 배치」 세월교 = 사용자 구조물 표 04 명세(컨테이너 셋) + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 목록에 「세월교 1」 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m524 = "단계";` 를 붙여 + * orca eval --expression "window.__m524='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠) + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m524='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 + * orca eval --expression "window.__m524='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) + * 기대 수치(edit): 컨테이너 3(세월교 · 유입 구조 · 유출 구조 · 모두 필수) · 관경 800/1000/1200/1500 · + * 구조 없음/날개벽/흙막이 · 옛 관종 · 날개벽 칸 안 보임 · 폭 8 → 높이 = 필요 수심(설계유량 있으면) · + * 낮게 적으면 필요 수심으로 되돌림 · 수량 3 · 잇기 세월교 · 유입 지정 · 유출 지정 뒤 풂 + * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) + */ +(async () => { + const MODE = window.__m524 || "edit"; + const NAME = /세월교 1$/; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c) => spec().querySelector(`[data-spec-container="${c}"]`); + const ctl = (c, item) => + box(c)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input"); + const set = async (c, item, v) => { + const el = ctl(c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(250); + }; + const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text); + const pickFord = async () => { + const find = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find( + (l) => l.offsetParent !== null && NAME.test(l.textContent.trim()), + ); + await until(() => !!find()); + const li = find(); + (li.querySelector("button") ?? li).click(); + await w(800); + if (!spec()) (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(500); + }; + const shape = () => + [...spec().querySelectorAll("[data-spec-container]")].map((b) => ({ + c: b.dataset.specContainer, + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map((n) => [ + n.dataset.specItem, + n.querySelector("select, input").value, + ]), + link: b.querySelector(".b05-structure__summary-name")?.textContent, + })); + const assigned = (c) => + box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); + const pickLink = async (c) => { + box(c).querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(500); + }; + const visibleKeys = () => + [...document.querySelectorAll(".b05-drainage__facility [data-option-keys]")] + .filter((n) => n.offsetParent !== null) + .map((n) => n.dataset.optionKeys); + const out = { mode: MODE }; + + if (MODE === "edit") { + const before = await fetchApi.fetchDetailPipePoints(projectId); + sessionStorage.setItem("__524_original", JSON.stringify(before.pipe_points)); + await pickFord(); + const s0 = shape(); + out.containers = s0.map((b) => [b.c, b.legend]); + out.oldFieldsVisible = visibleKeys(); // 옛 관종 · 월류 · 날개벽 칸 — 빈 목록이어야 + out.diameters = labels("ford", "diameter"); + out.inletChoices = labels("inlet", "structure"); + out.outletChoices = labels("outlet", "structure"); + out.start = s0.map((b) => b.items); + await set("ford", "diameter", "1200"); + await set("ford", "width", "8"); + out.heightAfterWidth8 = ctl("ford", "height").value; + out.heightHint = ctl("ford", "height").placeholder; + await set("ford", "width", "4"); + out.heightAfterWidth4 = ctl("ford", "height").value; + await set("ford", "height", "0.01"); + out.heightLowBounced = ctl("ford", "height").value; + const raised = (Number(out.heightAfterWidth4 || 0) + 0.2).toFixed(2); + await set("ford", "height", raised); + out.heightRaisedKept = ctl("ford", "height").value === raised; + await set("ford", "count", "3"); + await set("inlet", "structure", "흙막이"); + await set("outlet", "structure", "날개벽"); + await pickLink("ford"); + await pickLink("inlet"); + await pickLink("outlet"); + out.linksPicked = ["ford", "inlet", "outlet"].map(assigned); + box("outlet").querySelector('[data-b05-summary="clear"]').click(); + await w(300); + out.linkOutletCleared = !assigned("outlet"); + out.edited = shape().map((b) => b.items); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + const after = await fetchApi.fetchDetailPipePoints(projectId); + const p = after.pipe_points.find((x) => x.chainage_m > 170 && x.chainage_m < 176); // 세월교 1 = 8+13 + out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}) }; + } + + if (MODE === "verify") { + await pickFord(); + const b05 = shape(); + out.reopened = b05.map((b) => b.items); + out.links = ["ford", "inlet", "outlet"].map(assigned); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await pickFord(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + out.b06WingSlots = ["inlet", "outlet"].map( + (c) => box(c).querySelector(".b05-structure__adjust-slot")?.childElementCount ?? -1, + ); + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__524_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + const keys = [ + "chainage_m", + "source", + "facility", + "start_m", + "end_m", + "options", + "summary_item", + "group_items", + ]; + const points = original.map((p) => + Object.fromEntries( + keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]), + ), + ); + await fetchApi.saveDetailPipePoints(projectId, points); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:") || k === "__524_original") + sessionStorage.removeItem(k); + const now = await fetchApi.fetchDetailPipePoints(projectId); + const pick = (list) => + JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}])); + out.restored = pick(now.pipe_points) === pick(original); + } + return JSON.stringify(out); +})(); From 01ac9abf36de356cb99f20ce84e695a14796bc19 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:45:10 +0900 Subject: [PATCH 06/37] =?UTF-8?q?test(B05=C2=B7B06):=20=EB=B0=95=EC=8A=A4?= =?UTF-8?q?=EC=95=94=EA=B1=B0=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=8B=9C=EB=82=98=EB=A6=AC=EC=98=A4=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=97=B4=EA=B8=B0=20=C2=B7=20B05=20=3D=20B06=20=C2=B7=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=BC=20(PLAN=2052-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../ui_checks/52-2_박스암거_컨테이너.js | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 resources/tester/ui_checks/52-2_박스암거_컨테이너.js diff --git a/resources/tester/ui_checks/52-2_박스암거_컨테이너.js b/resources/tester/ui_checks/52-2_박스암거_컨테이너.js new file mode 100644 index 000000000..67c606819 --- /dev/null +++ b/resources/tester/ui_checks/52-2_박스암거_컨테이너.js @@ -0,0 +1,180 @@ +/* 52-2 — B05 · B06 좌측 「구조물 배치」 박스암거 = 사용자 구조물 표 02 고친 판(컨테이너 셋) + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub3_검증) · 목록에 「BOX암거」 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m522 = "단계";` 를 붙여 + * orca eval --expression "window.__m522='edit'; $(cat 이 파일)" → 고치고 [저장] (원래 관 지점은 sessionStorage 에 둠) + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m522='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 + * orca eval --expression "window.__m522='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 + * (연결이 끊기면 결과는 `sessionStorage.getItem("__522_out_{단계}")` 로 읽음) + * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) + * 기대 수치(edit): 컨테이너 3(본체 · 유입 구조 · 유출 구조 · 모두 필수 · [추가] 0) · 옛 값 3 × 2.5 = 사용자 입력 · + * 옛 「있음」 = 날개벽 · 규격 2.0 × 2.0 고르면 숫자 칸 0 · 사용자 입력 고르면 가로 · 세로 2 · + * 구조 선택지 없음/날개벽/흙막이 · 잇기 본체 · 유입 지정 · 유출 지정 뒤 풂 · + * 저장 = body 2.4 × 1.8 · wing_in 흙막이 · wing_out 없음 · group_items gboxbody · gboxin + * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) + */ +(async () => { + const MODE = window.__m522 || "edit"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const isBox = (p) => p.facility === "box_culvert"; + // 보이는 명세 폼(배관 · BOX 가 각자 폼을 가짐 — 숨은 것은 뺌) + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c) => spec()?.querySelector(`[data-spec-container="${c}"]`); + const field = (c, item) => box(c)?.querySelector(`[data-spec-item="${c}.${item}"]`); + const ctl = (c, item) => field(c, item)?.querySelector("select, input"); + const set = async (c, item, v) => { + const el = ctl(c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + }; + const labels = (c, item) => [...ctl(c, item).options].filter((o) => o.value).map((o) => o.text); + const pickBox = async () => { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /BOX암거$/.test(l.textContent.trim()), + ); + // 다시 열면 고른 것이 복원돼 있음 — 누르면 풀리므로 풀렸으면 한 번 더(재선택 = 해제) + (li.querySelector("button") ?? li).click(); + await w(800); + if (!box("body")) (li.querySelector("button") ?? li).click(); + await until(() => !!box("body")); + await w(500); + }; + const shape = () => + [...spec().querySelectorAll("[data-spec-container]")].map((b) => ({ + c: b.dataset.specContainer, + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem), + values: [...b.querySelectorAll("[data-spec-item]")].map( + (n) => n.querySelector("select, input").value, + ), + link: b.querySelector(".b05-structure__summary-name")?.textContent, + })); + const linked = (c) => + box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); + const pickLink = async (c) => { + box(c).querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((b) => b.textContent.includes("적용")) + .click(); + await w(500); + }; + const out = { mode: MODE }; + + if (MODE === "edit") { + const before = await fetchApi.fetchDetailPipePoints(projectId); + // 원래 값은 처음 한 번만 둠(끊겨 다시 돌려도 고친 값으로 덮지 않음 · 되돌림이 비움) + if (!sessionStorage.getItem("__522_original")) + sessionStorage.setItem("__522_original", JSON.stringify(before.pipe_points)); + out.original = before.pipe_points.find(isBox)?.options; + await pickBox(); + const s0 = shape(); + out.containers = s0.map((b) => [b.c, b.legend]); + out.allVisible = ["body", "inlet", "outlet"].every((c) => box(c)?.offsetParent !== null); + out.addButtons = spec().querySelectorAll("[data-spec-add]").length; + out.oldRead = { + body: s0[0].values, + inlet: ctl("inlet", "structure").value, + outlet: ctl("outlet", "structure").value, + }; + out.sizes = labels("body", "size"); + out.structures = labels("inlet", "structure"); + await set("body", "size", "2.0×2.0"); + out.presetNumberFields = ["width", "height"].filter((i) => field("body", i)).length; + await set("body", "size", "사용자 입력"); + out.customNumberFields = ["width", "height"] + .filter((i) => field("body", i)) + .map((i) => field("body", i).querySelector("label, span")?.textContent ?? i); + await set("body", "width", "2.4"); + await set("body", "height", "1.8"); + await set("inlet", "structure", "흙막이"); + await set("outlet", "structure", "없음"); + await pickLink("body"); + await pickLink("inlet"); + await pickLink("outlet"); + out.linkPicked = ["body", "inlet", "outlet"].map(linked); + box("outlet").querySelector('[data-b05-summary="clear"]').click(); + await w(300); + out.linkOutletCleared = !linked("outlet"); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + const after = await fetchApi.fetchDetailPipePoints(projectId); + const p = after.pipe_points.find(isBox); + out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}) }; + } + + if (MODE === "verify") { + await pickBox(); + const b05 = shape(); + out.reopened = b05.map((b) => [b.c, b.values, b.link]); + out.linkReopened = ["body", "inlet", "outlet"].map(linked); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await pickBox(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__522_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + const keys = [ + "chainage_m", + "source", + "facility", + "start_m", + "end_m", + "options", + "summary_item", + "group_items", + ]; + const points = original.map((p) => + Object.fromEntries( + keys.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]), + ), + ); + await fetchApi.saveDetailPipePoints(projectId, points); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:") || k === "__522_original") + sessionStorage.removeItem(k); + const now = await fetchApi.fetchDetailPipePoints(projectId); + const pick = (list) => + JSON.stringify(list.map((p) => [p.chainage_m, p.options ?? {}, p.group_items ?? {}])); + out.restored = pick(now.pipe_points) === pick(original); + } + // 긴 단계는 ORCA 연결이 끊길 수 있어 결과를 남김 — `sessionStorage.__522_out_{단계}` 로 읽음 + sessionStorage.setItem(`__522_out_${MODE}`, JSON.stringify(out)); + return JSON.stringify(out); +})(); From e197727a9b75a84ed81d0ad8de10848039cfad9a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:45:26 +0900 Subject: [PATCH 07/37] =?UTF-8?q?fix(structure):=20=EB=A7=B9=EC=95=94?= =?UTF-8?q?=EA=B1=B0=20=EC=A2=85=EB=A5=98=20=EB=8B=A8=EC=9C=84=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84=20=ED=95=AD=EB=AA=A9=20=EC=A4=84=20=EA=B0=80=EB=A6=BC?= =?UTF-8?q?(=ED=91=9C=2006=20=C2=B7=2052-6b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 52-1 규칙 넓힘 — 정의 그룹 상자(그룹마다 잇기 줄)가 있는 종류도 종류 단위 「구조물 조합」 줄 가림 - 정의 그룹 가진 종류 = 배관(이미 명세 종류) · 맹암거뿐 — 다른 종류 동작 · 저장 값 자리 그대로 - ORCA 52-6 시나리오에 잇기 줄 수(linkRows) 더함 — 맹암거 1 · 노출형 횡단수로 1 · 개거 1 · 배관 3 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts | 5 +++-- resources/tester/ui_checks/52-6_횡단맹암거.js | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 97032d397..e35163c76 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -23,9 +23,10 @@ import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { specKeys } from "../A00_Common/design/design_structure_spec"; -/** 구조물 명세 컨테이너로 그리는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1). */ +/** 구조물 명세 컨테이너 · 정의 그룹 상자(그룹마다 잇기 줄)로 그리는 종류인지 — 그때 종류 단위 + * 「구조물 조합」 줄은 가림(PLAN 52-1 · 52-6b). */ export const hasSpecContainers = (type: StructureType): boolean => - !!STRUCTURE_SPECS[type.type_id]?.containers.length; + !!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface OptionRenderContext { diff --git a/resources/tester/ui_checks/52-6_횡단맹암거.js b/resources/tester/ui_checks/52-6_횡단맹암거.js index 2ce14c7ee..3519947f5 100644 --- a/resources/tester/ui_checks/52-6_횡단맹암거.js +++ b/resources/tester/ui_checks/52-6_횡단맹암거.js @@ -7,7 +7,7 @@ * orca eval --expression "window.__m526='restore'; $(cat 이 파일)" → sessionStorage 를 처음 그대로 · 뒤에 orca reload * 쓰는 것: 없음 — [저장] 은 fetch 를 가로채 서버로 안 보냄(공유 storage) · 서버 저장 왕복은 test_52_6_cross_underdrain * 기대 수치(edit): b05Types 에 cross_underdrain:맹암거 · underdrain 없음 · box 맹암거 · keys 높이 · 경사 · - * pending 높이 0.6 · 경사 2.5 · 잇기 gcrossud 지정 · reopened 같음 + * 잇기 줄 1(맹암거 상자 안만 · 종류 단위 줄 가림 · 52-6b) · pending 높이 0.6 · 경사 2.5 · 잇기 gcrossud 지정 · reopened 같음 * 기대 수치(b06): b06Types A 에 맹암거 · B 에 종단배수 맹암거 따로 · sameDrawing · 값 같음 * 기대 수치(save): saved = PUT 몸통에 같은 값(67 m · 0.6 · 2.5 · gcrossud) */ @@ -55,6 +55,7 @@ values: ["culvert_height_m", "culvert_slope_pct"].map((k) => input(k)?.value), link: b?.querySelector(".b05-structure__summary-name").textContent, linked: b?.querySelector(".b05-structure__summary-name").classList.contains("is-assigned"), + linkRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length, }; }; const item = () => From 6bd9773d0fbc6360a1942e521251b378d90f5b47 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:45:46 +0900 Subject: [PATCH 08/37] =?UTF-8?q?feat(B05=C2=B7B06):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A2=85=EB=8B=A8=EB=B0=B0=EC=88=98=C2=B7?= =?UTF-8?q?=EA=B3=84=EB=A5=98=EC=82=AC=EB=B0=A9=20=EC=88=A8=EA=B9=80=20(PL?= =?UTF-8?q?AN=2052-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 군 드롭다운 · 우클릭 메뉴에서 B · D 군 뺌 — 옛 값 · 도면 · 집계 그대로 - 종단배수 종류 단위 조합 잇기 줄 숨김 - 정의 폼의 가림 풀기가 군 숨김을 건드리지 않게 표식 추가 - 화면 시나리오 52-8_종단_계류_숨김.js Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../B05_Profile_UI_Profile_Structures.ts | 5 +- .../B05_Profile_UI_Structures_Define_Form.ts | 4 +- .../B05_Profile_UI_Structures_Panel.ts | 7 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 7 +- .../B05_Profile_UI_Structures_Panel_Types.ts | 12 +++ .../tester/ui_checks/52-8_종단_계류_숨김.js | 99 +++++++++++++++++++ 6 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 resources/tester/ui_checks/52-8_종단_계류_숨김.js diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index c22646ad4..4eb2380a8 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -17,6 +17,7 @@ import { } from "./B05_Profile_UI_IrregularStations"; import { structureAnchorM, type StructureInstance } from "./B05_Profile_Api_Structures"; import { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel"; +import { HIDDEN_PICK_GROUPS } from "./B05_Profile_UI_Structures_Panel_Types"; /** 우클릭 메뉴용 최소 타입 정보 — 사이드 「구조물 배치」 종류 목록과 같은 원천. */ export interface StructureMenuType { @@ -32,7 +33,9 @@ export function structureGroupMenuItems( types: ReadonlyArray, onPick: (typeId: string) => void, ): MapContextMenuItem[] { - const groups = [...new Set(types.map((type) => type.group))]; + const groups = [...new Set(types.map((type) => type.group))].filter( + (group) => !HIDDEN_PICK_GROUPS.has(group), + ); return groups.map((group) => ({ label: GROUP_LABELS[group] ?? group, children: types diff --git a/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts index e3e44d790..7f321904a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts @@ -78,7 +78,9 @@ export function createFacilityDefinition(root: HTMLElement): FacilityDefinition root .querySelectorAll(".is-rule-hidden") .forEach((node) => node.classList.remove("is-rule-hidden")); - root.querySelectorAll("option[hidden]").forEach((o) => (o.hidden = false)); + root + .querySelectorAll("option[hidden]:not([data-pick-hidden])") + .forEach((o) => (o.hidden = false)); paint(); }, }; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 7f1c2ffe0..ffae71d9d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -24,7 +24,7 @@ import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { - hasSpecContainers, + hidesTypeSummary, paintStale, renderOptionFields as renderOptionFieldsInto, } from "./B05_Profile_UI_Structures_Panel_Options"; @@ -54,6 +54,7 @@ export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Struc import { DEFAULT_INTERVAL_LENGTH_M, GROUP_LABELS, + groupOption, hasComputedRange, readBeforeOption, readLengthOption, @@ -227,7 +228,7 @@ export function createStructuresSection( !!type && !managed && type.placement === "interval" && !hasComputedRange(type); positionRow.hidden = !type; positionDivider.hidden = !type; - summary.root.hidden = !type || hasSpecContainers(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1) + summary.root.hidden = !type || hidesTypeSummary(type); // 컨테이너 종류는 컨테이너마다 잇기(52-1) startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; primary.disabled = !type; @@ -544,7 +545,7 @@ export function createStructuresSection( // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저. groupSelect.replaceChildren( new Option("— 선택 —", ""), - ...[...groups].map(([g, name]) => new Option(name ?? GROUP_LABELS[g] ?? g, g)), + ...[...groups].map(([g, name]) => groupOption(g, name ?? GROUP_LABELS[g] ?? g)), ); groupSelect.value = ""; syncTypeOptions(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 97032d397..8fe069b7d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -23,9 +23,10 @@ import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { specKeys } from "../A00_Common/design/design_structure_spec"; -/** 구조물 명세 컨테이너로 그리는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1). */ -export const hasSpecContainers = (type: StructureType): boolean => - !!STRUCTURE_SPECS[type.type_id]?.containers.length; +/** 종류 단위 「구조물 조합」 줄을 가릴 종류인지 — 구조물 명세 컨테이너로 그리는 종류(PLAN 52-1) · + * 종단배수(B군 · 조합 잇기 없음 · 표 07). */ +export const hidesTypeSummary = (type: StructureType): boolean => + !!STRUCTURE_SPECS[type.type_id]?.containers.length || type.group === "B"; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface OptionRenderContext { diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index ff1daad7a..04fa037cd 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -34,6 +34,18 @@ export const GROUP_LABELS: Record = { 호환: "기타", }; +/** 새로 놓을 때 고르기 목록(군 드롭다운 · 우클릭 메뉴)에서 뺄 군 — 종단배수·계류사방(표 07). + * 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */ +export const HIDDEN_PICK_GROUPS: ReadonlySet = new Set(["B", "D"]); + +/** 군 드롭다운 항목 — 뺄 군은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */ +export function groupOption(group: string, name: string): HTMLOptionElement { + const option = new Option(name, group); + option.hidden = HIDDEN_PICK_GROUPS.has(group); + if (option.hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게 + return option; +} + /** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ export const DEFAULT_INTERVAL_LENGTH_M = 15; diff --git a/resources/tester/ui_checks/52-8_종단_계류_숨김.js b/resources/tester/ui_checks/52-8_종단_계류_숨김.js new file mode 100644 index 000000000..d77b951a7 --- /dev/null +++ b/resources/tester/ui_checks/52-8_종단_계류_숨김.js @@ -0,0 +1,99 @@ +/* 52-8 — B05 · B06 좌측 「구조물 배치」 종단배수(B) · 계류사방(D) 고르기 목록에서 숨김 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub2_검증) + * 부르는 법: 앞에 `window.__m528 = "단계";` 를 붙여 + * orca eval --expression "window.__m528='seed'; $(cat 이 파일)" → 옛 값 둘(골막이 D · 측구 B)을 세션 초안에만 둠 + * (orca reload) + * orca eval --expression "window.__m528='verify'; $(cat 이 파일)" → 군 목록 · 우클릭 메뉴 · 옛 값 열기 (B05) + * (#/b06-section 으로 가서 reload 후 verify 한 번 더) + * orca eval --expression "window.__m528='clear'; $(cat 이 파일)" → 세션 초안 비움(서버 저장 안 함) + * 기대 수치(verify): visibleGroups 에 B · D 없음(0) · hiddenGroups 가림만 · menuHasBD 0 · + * 옛 값 목록 둘 그대로 · 옛 값을 열면 군 = D / B 로 열림(editGroup) · 잇기 칸 pickLinks 골막이(D) 1 · 측구(B) 0 + */ +(async () => { + const MODE = window.__m528 || "verify"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts")); + const out = { mode: MODE, hash: location.hash }; + if (MODE === "seed") { + const base = { + placement_source: "manual", + status: "draft", + revision: 0, + geometry: null, + memo: "", + options: {}, + }; + api.writePendingStructures(projectId, [ + { + ...base, + structure_id: "m528d", + type_id: "erosion_check", + placement: "point", + chainage_m: 40, + }, + { + ...base, + structure_id: "m528b", + type_id: "ditch_side", + placement: "interval", + start_m: 60, + end_m: 80, + }, + ]); + out.seeded = api.readPendingStructures(projectId).length; + return JSON.stringify(out); + } + if (MODE === "clear") { + api.writePendingStructures(projectId, null); + out.cleared = api.readPendingStructures(projectId) === null; + return JSON.stringify(out); + } + const groupSel = [...document.querySelectorAll("select")].find((s) => + [...s.options].some((o) => o.text.includes("A 횡단배수")), + ); + const isBD = (o) => /^(B|D) /.test(o.text); + out.visibleGroups = [...groupSel.options].filter((o) => !o.hidden && o.value).map((o) => o.text); + out.visibleBD = [...groupSel.options].filter((o) => !o.hidden && isBD(o)).length; + out.hiddenGroups = [...groupSel.options].filter((o) => o.hidden).map((o) => o.text); + // 우클릭 메뉴 목록(공용 만들기) — 종류 전부를 줘도 B · D 군이 안 나옴 + const menuMod = await import(modUrl("/B05_Profile/B05_Profile_UI_Profile_Structures.ts")); + const types = (await api.fetchStructureTypes(true)).map((t) => ({ + type_id: t.type_id, + group: t.group, + name: t.name, + })); + const menu = menuMod.structureGroupMenuItems(types, () => {}); + out.menuGroups = menu.map((m) => m.label); + out.menuHasBD = menu.filter((m) => /^(B|D) /.test(m.label)).length; + // 옛 값 — 목록에 그대로 · 열면 그 군으로 열림 + const rows = [...document.querySelectorAll(".b05-route__irregular-list li")]; + out.rows = rows.map((r) => r.textContent.trim().replace(/\s+/g, " ").slice(0, 40)); + const old = rows.find((r) => + (window.__m528row ? new RegExp(window.__m528row) : /골막이/).test(r.textContent), + ); + if (old) { + (old.querySelector("button") ?? old).click(); + await w(1500); + const sels = [...document.querySelectorAll("select")].filter((s) => + [...s.options].some((o) => o.text.includes("A 횡단배수")), + ); + // 다시 열면 고른 것이 복원돼 있음 — 누르면 풀리므로 풀렸으면 한 번 더 + if (!sels[0]?.value) { + (old.querySelector("button") ?? old).click(); + await w(1500); + } + out.editGroup = sels[0]?.value; + // 종단배수(B)는 조합 잇기 칸이 없음(0) · 계류사방(D)은 옛 그대로(1) + out.pickLinks = [...document.querySelectorAll('[data-b05-summary="pick"]')].filter( + (n) => n.offsetParent, + ).length; + } + return JSON.stringify(out); +})(); From 3cebfc6d0ae53f9c4292e511d429f2d9973e5939 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:47:37 +0900 Subject: [PATCH 09/37] =?UTF-8?q?chore(plan):=2010-5=20=EB=B0=A4=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=A7=80=EC=8B=9C(=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=ED=8C=A8=EB=84=90=20=C2=B7=20=EA=B8=B0=EC=A1=B4=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=97=B0=EA=B2=B0=EB=A7=8C)=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index bebbfa64f..81999c1c9 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -119,3 +119,6 @@ 추가로 구조물 조합의 일위대가 상세페이지의 로직 테이블의 순서 열에서 존재하는 버튼을 2행 구조로 변경 단가 승격에도 옵션 버튼이있어야할것같고. 일위조합의 경우에는 바로 옵션으로 지정할수 없으니 수정버튼(모달)도 잇어할것 같은데? + +맹암거처럼 cad연결이나 횡단도에 그릴지 말지는 이번 수정 후에 작업할거야. 너는 B05/B06 좌측 패널의 생성과 기존 로직 연결에만 신경쓸것. +나는 이제 자러 갈거니 너가 다른 워크트리들을 잘 제어해서 계획서의 내용을 반영하고 최종검증을 진행 후 아티팩트로 결과를 보여주는데 만약 개선이 필요하다고 판단되는 부분이 있다면 계획서에 내용을 추가해서 개선할것. From 5a85e0b32137b52349704599770a71eea13c4c79 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:48:27 +0900 Subject: [PATCH 10/37] =?UTF-8?q?feat(B05):=20=EC=95=88=EC=A0=84=EB=B6=80?= =?UTF-8?q?=EB=8C=80=EC=9A=A9=EC=A7=80=20=3D=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=ED=91=9C=2007=20=E2=80=94=20=EB=8C=80=ED=94=BC=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EA=B0=80=EB=93=9C=EB=A0=88=EC=9D=BC=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88=20=C2=B7=20=EC=A0=90=ED=98=95=20?= =?UTF-8?q?=EC=9D=BC=EA=B3=B1=20(PLAN=2052-7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정의 판 7(판 6 이력) — 대피로(evac_road · 점형 · 상세 = 대피소 · 정차/작업장 · 차돌림곳) · 경계석 · 위험표시 · 취수정 새 키 · 가드레일 = 형태(손 입력) · 길이 · 기준측점 전 · 후 · 대피로 · 가드레일 「상세 내용」 컨테이너(정의 그룹 · 잇기 1) - 반사경 방향 · 가드레일 옛 종류(kind)는 화면에서 빼고 지난 정의 값으로 남김 - 옛 대피소 · 정차·작업장 · 차돌림곳 = 고르기 목록에서 빼고 대피로 + 상세로 읽음 · 고치기 전까지 옛 종류 그대로 · 고쳐도 옛 칸 값 남김 - 가드레일 길이 = 전 + 후 = 배관 맞물림 solveSplit 그대로 - 컨테이너 종류(정의 그룹 있음)는 종류 단위 잇기 줄 가림 · 가린 시설 폼 칸이 그룹 상자로 딸려 오던 흠 막음 - 집계 알림 「가드레일·경계석·위험표지 종류 미정」 걷음 - 시험 test_52_7_safety · 정책 시험 B05 선택지 27 · ORCA ui_checks/52-7_안전부대용지 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../B05_Profile_UI_Structures_Define.ts | 22 + .../B05_Profile_UI_Structures_Groups.ts | 5 +- .../B05_Profile_UI_Structures_Panel.ts | 4 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 12 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 35 +- M02_MasterTemplete/M02_Table_Fill.py | 5 - resources/master_template/구조물정의.json | 153 +- .../master_template/구조물정의_이력/판_6.json | 3360 +++++++++++++++++ resources/tester/test_52_7_safety.py | 101 + .../test_b05_structures_registry_policy.py | 6 +- .../tester/ui_checks/52-7_안전부대용지.js | 218 ++ 11 files changed, 3875 insertions(+), 46 deletions(-) create mode 100644 resources/master_template/구조물정의_이력/판_6.json create mode 100644 resources/tester/test_52_7_safety.py create mode 100644 resources/tester/ui_checks/52-7_안전부대용지.js diff --git a/B05_Profile/B05_Profile_UI_Structures_Define.ts b/B05_Profile/B05_Profile_UI_Structures_Define.ts index 4c5ecf599..b3ef3f123 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Define.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Define.ts @@ -215,3 +215,25 @@ export function withDefinition< }; }); } + +/** 대피로(표 07 · PLAN 52-7) — 옛 대피 종류는 고르기 목록에서 빼고 대피로 + 상세로 읽음. + * 옛 기록은 고치기 전까지 옛 종류 그대로(도면 · 집계 그대로) · 고치면 대피로로 저장 · 옛 칸 값은 남음. */ +export const ROAD_TYPE = "evac_road"; +export const ROAD_DETAILS: Readonly> = { + refuge: "대피소", + work_yard: "정차/작업장", + turnaround: "차돌림곳", +}; + +/** 고르기 목록에 낼 종류인지 — 옛 대피 종류만 뺌. */ +export const pickable = (type: { type_id: string }): boolean => !ROAD_DETAILS[type.type_id]; + +/** 옛 대피 종류 기록 → 대피로 + 상세(옛 값 그대로 · 상세만 채움). 다른 기록은 그대로. */ +export function asRoad }>( + record: T, +): T { + const detail = ROAD_DETAILS[record.type_id]; + return detail + ? { ...record, type_id: ROAD_TYPE, options: { detail, ...record.options } } + : record; +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts index f41119d99..63b1afc19 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Groups.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -136,9 +136,12 @@ export function createGroupBoxes(): GroupBoxes { for (const scope of scopes) { const marked = [...scope.querySelectorAll("[data-option-keys]")]; // 지금 숨은 칸(다른 시설 몫 · 안 고른 갈래)은 그 자리에 둠 — 같은 키 칸이 두 벌 옮겨 오지 않게 + // 자리 자체가 가려졌으면(안 쓰는 시설 폼) 그 칸도 숨은 것 — 같은 키 칸이 딸려 오지 않게(52-7) const shown = (node: HTMLElement): boolean => { - for (let at: HTMLElement | null = node; at && at !== scope; at = at.parentElement) + for (let at: HTMLElement | null = node; at; at = at.parentElement) { if (at.hidden) return false; + if (at === scope) break; + } return true; }; const fits = marked.filter( diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 7f1c2ffe0..fc4d6de2a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -42,7 +42,7 @@ import { loadPipeForm as loadPipeFormInto, } from "./B05_Profile_UI_Structures_Panel_Commit"; import { renderStructureList } from "./B05_Profile_UI_Structures_List"; -import { showsAnchor } from "./B05_Profile_UI_Structures_Define"; +import { pickable, showsAnchor } from "./B05_Profile_UI_Structures_Define"; import { hiddenOptionKeys } from "./B05_Profile_UI_Structures_Define_Form"; import { stationLabel as formatStation } from "../common_util/common_util_station"; import { PIPE_MATCH_M, resolveSelection } from "../A00_Common/design/design_selection"; @@ -344,7 +344,7 @@ export function createStructuresSection( * 초기·리셋 상태를 "안 고름"으로 드러낸다. */ function syncTypeOptions(keepTypeId?: string): void { const group = groupSelect.value; - const candidates = group ? types.filter((type) => type.group === group) : []; + const candidates = group ? types.filter((t) => t.group === group && pickable(t)) : []; typeSelect.replaceChildren( new Option("— 선택 —", ""), ...candidates.map((type) => new Option(type.name, type.type_id)), diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index e24155203..3bf459a6a 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -18,6 +18,7 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; +import { asRoad } from "./B05_Profile_UI_Structures_Define"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { readGroupItems, @@ -95,16 +96,17 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance ctx.setPositionConfirmed(target !== null); const step = ctx.interval(); if (target) { - const type = ctx.typeMap().get(target.type_id); + const shown = asRoad(target); // 옛 대피 종류 = 대피로 + 상세(52-7) + const type = ctx.typeMap().get(shown.type_id); if (type) { ctx.groupSelect.value = type.group; - ctx.syncTypeOptions(target.type_id); + ctx.syncTypeOptions(shown.type_id); } ctx.anchorFields.write(structureAnchorM(target), step); ctx.startFields.write(target.start_m ?? null, step); ctx.endFields.write(target.end_m ?? null, step); ctx.memoField.value = target.memo ?? ""; - ctx.renderOptionFields(target.options); + ctx.renderOptionFields(shown.options); ctx.setSummary(readSummaryItem(target.summary_item)); ctx.groupBoxes.set(readGroupItems(target.group_items)); ctx.groupBoxes.setShapes(target.options); @@ -128,7 +130,7 @@ export function loadForm(ctx: StructuresCommitContext, target: StructureInstance afterEntry.input.value = after.toFixed(1); } } - ctx.syncFacilityForm(target.options); + ctx.syncFacilityForm(shown.options); } else { ctx.anchorFields.write(null, step); ctx.startFields.write(null, step); @@ -367,7 +369,7 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis ...previous, ...records[0], options: mergeStructureOptions( - previous, + asRoad(previous), // 옛 대피 종류를 대피로로 고쳐도 옛 칸 값은 남김(52-7) type.type_id, [...ctx.optionInputs.map((entry) => entry.key), ...ctx.groupBoxes.shapeKeys()], records[0].options, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 97032d397..cab419389 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -21,11 +21,19 @@ import { } from "./B05_Profile_UI_Structures_Define"; import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; -import { specKeys } from "../A00_Common/design/design_structure_spec"; +import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec"; -/** 구조물 명세 컨테이너로 그리는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1). */ +/** 컨테이너로 그리는 종류인지(명세 · 정의 그룹 상자) — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1 · 52-7). */ export const hasSpecContainers = (type: StructureType): boolean => - !!STRUCTURE_SPECS[type.type_id]?.containers.length; + !!STRUCTURE_SPECS[type.type_id]?.containers.length || !!type.groups?.length; + +/** 길이 = 전 + 후 를 배관 맞물림(`solveSplit` · 마지막에 고친 둘을 지킴)으로 푸는 종류(표 07 가드레일). */ +const SOLVE_SPLIT = new Set(["guardrail"]); +const SPLIT_ROLE: Record = { + length_m: "length", + before_m: "before", + after_m: "after", +}; /** 패널이 쥔 값 중 옵션 그리기에 필요한 것들. 이름은 분리 전 지역변수와 같다. */ export interface OptionRenderContext { @@ -127,12 +135,31 @@ export function renderOptionFields( "length" | "before_m" | "after_m"; const refresh = (normalize: boolean): void => { input.classList.remove("is-invalid"); - ctx.syncSplitInputs(source, normalize); + if (SOLVE_SPLIT.has(type!.type_id)) { + solveInto(option.key); // 타이핑 중에도 — change 의 실시간 반영이 푼 값을 읽게 + ctx.syncRangeDisplay(); + } else ctx.syncSplitInputs(source, normalize); }; input.addEventListener("input", () => refresh(false)); input.addEventListener("change", () => refresh(true)); } }); + /** 길이 · 전 · 후 맞물림 — 고친 차례(최근이 앞)를 쥐고 나머지 하나를 셈. */ + const recent: SplitRole[] = []; + function solveInto(key: string): void { + const role = SPLIT_ROLE[key]; + recent.splice(0, recent.length, role, ...recent.filter((r) => r !== role)); + const inputOf = (r: SplitRole) => + all.find(({ entry }) => SPLIT_ROLE[entry.key] === r)?.entry.input; + const values = Object.fromEntries( + (["length", "before", "after"] as SplitRole[]).map((r) => [r, inputOf(r)?.value ?? ""]), + ); + const solved = solveSplit(values, recent); + for (const r of ["length", "before", "after"] as SplitRole[]) { + const input = inputOf(r); + if (input) input.value = solved[r] ?? ""; + } + } /** 규칙 걸기 — `parent` 가 바뀐 칸이면 그 칸에 매인 기본값부터 넣음. */ function applyRules(parent: string | null): void { const now = (): Record => diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index a55dc4341..214c355c0 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -511,11 +511,6 @@ def fill_document( missing = sum(1 for item in items if item["type_id"] == "pipe" and not item.get("관연장")) if missing: notes.append(f"관 연장 없음 {missing}곳 — B06 횡단 설계 전이면 빈칸") - loose = sum( - 1 for item in items if item["type_id"] == "guardrail" and not item["options"].get("kind") - ) - if loose: - notes.append(f"가드레일·경계석·위험표지 종류 미정 {loose}건 — 표에 안 셈") # 손 값 지키기 — 같은 줄 id 의 손 열 · 전구간 줄 · 사용자가 더한 줄 hand = _hand_ids(columns) diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 5b2e2bf07..8c77cb1ae 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 6, + "판": 7, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -2633,6 +2633,36 @@ } ] }, + { + "type_id": "evac_road", + "group": "E", + "name": "대피로", + "placement": "point", + "style": { + "color": "#2fa84f", + "abbr": "대피로" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "detail", + "label": "상세", + "input": "select", + "choices": [ + "대피소", + "정차/작업장", + "차돌림곳" + ], + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 상세 미정 — 대피로 자리만 잡음(대피소 · 정차/작업장 · 차돌림곳은 뒤에 고름)" + } + ] + }, { "type_id": "refuge", "group": "E", @@ -2732,26 +2762,40 @@ "drawing_views": [ "plan" ], - "options": [ - { - "key": "facing", - "label": "방향", - "input": "select", - "choices": [ - "좌", - "우", - "양면" - ], - "default": null, - "required": true, - "phase": "detail" - } - ] + "options": [] + }, + { + "type_id": "curb", + "group": "E", + "name": "경계석", + "placement": "point", + "style": { + "color": "#4fae5c", + "abbr": "경계석" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "hazard_sign", + "group": "E", + "name": "위험표시", + "placement": "point", + "style": { + "color": "#5fb83a", + "abbr": "위험" + }, + "drawing_views": [ + "plan" + ], + "options": [] }, { "type_id": "guardrail", "group": "E", - "name": "가드레일·경계석·위험표지", + "name": "가드레일", "placement": "interval", "style": { "color": "#1f9e46", @@ -2764,17 +2808,35 @@ ], "options": [ { - "key": "kind", - "label": "종류", - "input": "select", - "choices": [ - "가드레일", - "경계석", - "위험표지" - ], + "key": "form", + "label": "형태", + "input": "text", "default": null, - "required": true, - "phase": "detail" + "required": false + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 범위를 못 셈 — 길이를 적어야 기준 측점 앞뒤로 구간이 섬" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 0 — 기준 측점이 곧 시작(길이 = 전 + 후 맞물림)" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": null, + "empty_means": "비우면 길이 − 전 으로 셈(길이 = 전 + 후 맞물림)" } ] }, @@ -2793,6 +2855,20 @@ ], "options": [] }, + { + "type_id": "water_intake", + "group": "E", + "name": "취수정", + "placement": "point", + "style": { + "color": "#3a9e78", + "abbr": "취수정" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, { "type_id": "barrier_gate", "group": "E", @@ -3352,6 +3428,29 @@ "옵션": [], "형상": {} } + ], + "evac_road": [ + { + "id": "gevacdetail", + "이름": "상세 내용", + "옵션": [ + "detail" + ], + "형상": {} + } + ], + "guardrail": [ + { + "id": "gguarddetail", + "이름": "상세 내용", + "옵션": [ + "form", + "length_m", + "before_m", + "after_m" + ], + "형상": {} + } ] }, "배치": {}, diff --git a/resources/master_template/구조물정의_이력/판_6.json b/resources/master_template/구조물정의_이력/판_6.json new file mode 100644 index 000000000..1be3c8ef2 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_6.json @@ -0,0 +1,3360 @@ +{ + "판": 6, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": null, + "required": false + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "ditch_spec", + "label": "규격", + "input": "select", + "choices": [ + "콘크리트 개거 150×200", + "L형수로 H=0.2" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎" + }, + { + "key": "length_m", + "label": "연장", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)" + } + ] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "콘크리트", + "돌(찰)", + "돌(메)", + "블록쌓기", + "돌망태", + "흙포대", + "통나무쌓기", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "text", + "default": null, + "required": false + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": null + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": null + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": null + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(구 사토장)", + "placement": "interval", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "쌓는 쪽", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "required": false, + "phase": "b05", + "default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈" + }, + { + "key": "fill_slope_ratio", + "label": "비탈 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리" + }, + { + "key": "extra_distance_m", + "label": "추가 운반거리", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "detail", + "default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.structure": "구조", + "soil_add.structure=기슭막이": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ] + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ] + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": { + "pipe": { + "pipe_kind": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/test_52_7_safety.py b/resources/tester/test_52_7_safety.py new file mode 100644 index 000000000..824382bef --- /dev/null +++ b/resources/tester/test_52_7_safety.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +"""안전부대용지(표 07) — PLAN 52-7. + +정의(대피로 · 가드레일 컨테이너 · 점형 일곱) · 옛 대피 종류 읽기(`asRoad` · `pickable`) · +화면은 ORCA `ui_checks/52-7_안전부대용지.js`. +""" + +import json +import os +import subprocess + +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from M02_MasterTemplete.StructOptions import M02_SO_Definition as definition + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +POINTS = [ + "mirror", + "curb", + "hazard_sign", + "position_sign", + "water_intake", + "completion_sign", + "barrier_gate", +] + + +def test_정의_검사_통과(): + assert definition.check(definition.read()) == [] + + +def test_컨테이너_없는_일곱은_점형_옵션_없음(): + types = structure_type_map() + for tid in POINTS: + t = types[tid] + assert (t.group, t.placement, t.options) == ("E", "point", []), tid + assert [types[t].name for t in ("curb", "hazard_sign", "water_intake")] == [ + "경계석", + "위험표시", + "취수정", + ] + + +def test_대피로_가드레일_상세_내용_컨테이너(): + doc = definition.read() + groups = doc["그룹"] + assert groups["evac_road"] == [ + {"id": "gevacdetail", "이름": "상세 내용", "옵션": ["detail"], "형상": {}} + ] + assert groups["guardrail"][0]["옵션"] == ["form", "length_m", "before_m", "after_m"] + types = structure_type_map() + road = types["evac_road"] + assert road.placement == "point" + assert road.options[0].choices == ["대피소", "정차/작업장", "차돌림곳"] + guard = types["guardrail"] + assert guard.name == "가드레일" and guard.placement == "interval" + assert [(o.key, o.input) for o in guard.options] == [ + ("form", "text"), + ("length_m", "number"), + ("before_m", "number"), + ("after_m", "number"), + ] + # 옛 대피 종류는 정의에 그대로(옛 기록 도면 · 집계) + assert {"refuge", "work_yard", "turnaround"} <= set(types) + + +NODE = r""" +const fs = require("fs"), path = require("path"); +const ts = require(path.join(process.argv[1], "config", "node_modules", "typescript")); +const src = fs.readFileSync( + path.join(process.argv[1], "B05_Profile", "B05_Profile_UI_Structures_Define.ts"), "utf8"); +const js = ts.transpileModule(src, { + compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText; +const m = { exports: {} }; +new Function("exports", "module", js)(m.exports, m); +const d = m.exports; +process.stdout.write(JSON.stringify({ + old: d.asRoad({ type_id: "turnaround", options: { width_m: 10 } }), + other: d.asRoad({ type_id: "mirror", options: { facing: "좌" } }), + pick: ["refuge", "work_yard", "turnaround", "evac_road", "guardrail"].map( + (type_id) => d.pickable({ type_id })), +})); +""" + + +def test_옛_대피_종류는_대피로_상세로_읽고_목록에서_뺌(): + proc = subprocess.run( + ["node", "-e", NODE, ROOT], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["old"] == { + "type_id": "evac_road", + "options": {"detail": "차돌림곳", "width_m": 10}, + } + assert out["other"] == {"type_id": "mirror", "options": {"facing": "좌"}} + assert out["pick"] == [False, False, False, True, True] diff --git a/resources/tester/test_b05_structures_registry_policy.py b/resources/tester/test_b05_structures_registry_policy.py index 2f2c006c7..22f41b59e 100644 --- a/resources/tester/test_b05_structures_registry_policy.py +++ b/resources/tester/test_b05_structures_registry_policy.py @@ -189,7 +189,7 @@ def test_quick_add_types_exclude_pipe_family(): def test_all_enabled_non_managed_types_are_quick_addable(): - """잔존 B05 선택지 = A군 2(노출형·개거) + C군 5 + D군 3 + E군 10 + G군 1(콘크리트 포장) + 기타 1. + """잔존 B05 선택지 = A군 2(노출형·개거) + C군 5 + D군 3 + E군 14 + G군 1(콘크리트 포장) + 기타 1. 콘크리트 포장은 2026-08-28 사용자 지시로 되살렸다 — 포장 구간을 기준측점+길이+전/후로 받는 정본이 됐다(종단경사 자동 포장 적용을 대체). @@ -201,8 +201,10 @@ def test_all_enabled_non_managed_types_are_quick_addable(): 소단 규격은 법령·교본마다 갈려 프로그램이 판정하지 않고 **사용자가 구간에 놓는다** — 그래서 C군 구간형 시설과 같은 자리에 선다. (측구쪽 `ditch_berm` 은 우클릭 추가 대상이 아니라 여기 수에 안 잡힌다.) + + E군이 10에서 14로 늘었다 — 표 07(PLAN 52-7) 대피로 · 경계석 · 위험표시 · 취수정. """ - assert len(_quick_add_types()) == 23 + assert len(_quick_add_types()) == 27 def test_removed_types_are_absent(): diff --git a/resources/tester/ui_checks/52-7_안전부대용지.js b/resources/tester/ui_checks/52-7_안전부대용지.js new file mode 100644 index 000000000..bfe28c65b --- /dev/null +++ b/resources/tester/ui_checks/52-7_안전부대용지.js @@ -0,0 +1,218 @@ +/* 52-7 — B05 · B06 좌측 「구조물 배치」 안전부대용지 = 사용자 구조물 표 07 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m527 = "단계";` 를 붙여 + * seed → 원래 구조물을 sessionStorage 에 두고 옛 「대피소」 한 건(refuge · 너비 4 · 유효길이 15)을 더해 PUT + * (orca reload) + * edit → 종류 목록 · 일곱 종류 컨테이너 0 / 잇기 1 · 대피로 상세 3 · 옛 대피소 = 대피로 + 대피소 · + * 가드레일 길이 10 전 3 → 후 7 · 가드레일 · 대피로 [추가] → [저장] + * (orca reload) + * verify → 다시 연 값 · 옛 대피소 기록 그대로 · B05 = B06 같은 그리기 + * restore → 원래 구조물로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 원래 목록으로 다시 씀) + */ +(async () => { + const MODE = window.__m527 || "edit"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const api = await import(modUrl("/B05_Profile/B05_Profile_Api_Structures.ts")); + const section = () => document.querySelector(".b05-structure-section"); + const fieldOf = (label) => + [...section().querySelectorAll(".b05-route__field")].find( + (f) => f.querySelector("span")?.textContent.trim() === label, + ); + const groupSelect = () => fieldOf("구조물군").querySelector("select"); + const typeSelect = () => fieldOf("종류").querySelector("select"); + const choose = async (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(300); + }; + const pickType = async (typeId) => { + await choose(groupSelect(), "E"); + await choose(typeSelect(), typeId); + }; + const anchor = () => fieldOf("기준 측점")?.querySelectorAll("input"); + const place = async (station, plus) => { + const [s, r] = anchor(); + s.value = String(station); + s.dispatchEvent(new Event("change", { bubbles: true })); + r.value = String(plus); + r.dispatchEvent(new Event("change", { bubbles: true })); + await w(400); + }; + // 보이는 칸만 — 안 쓰는 시설 폼에도 같은 키 칸(포장 길이 등)이 숨어 있음 + const opt = (key) => + [...section().querySelectorAll(`[data-option-keys="${key}"]`)] + .find((n) => n.offsetParent !== null) + ?.querySelector("input, select"); + const setOpt = async (key, value) => { + const el = opt(key); + el.value = value; + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(300); + }; + const shown = (el) => !!el && el.offsetParent !== null; + // 종류 단위 잇기 = 기준 측점 아래 줄(컨테이너 밖) · 컨테이너 = 정의 그룹 상자 + const typeLinks = () => + [...section().querySelectorAll('[data-b05-summary="row"]')].filter( + (r) => !r.closest("[data-b05-group]") && shown(r), + ).length; + const containers = () => + [...section().querySelectorAll("[data-b05-group]")].filter(shown).map((box) => ({ + title: box.querySelector("legend").textContent, + keys: [...box.querySelectorAll("[data-option-keys]")].map((n) => n.dataset.optionKeys), + links: box.querySelectorAll('[data-b05-summary="row"]').length, + })); + const listItem = (name) => + [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + l.textContent.includes(name), + ); + const openItem = async (name) => { + await until(() => listItem(name)); + const li = listItem(name); + (li.querySelector("button") ?? li).click(); + await w(600); + if (!typeSelect().value) { + (listItem(name).querySelector("button") ?? listItem(name)).click(); // 재선택 = 해제였으면 다시 + await w(600); + } + }; + const stale = () => + [...section().querySelectorAll(".b05-structure__stale .b05-route__field")].map((f) => [ + f.querySelector("span").textContent, + f.querySelector("input").value, + ]); + const form = () => ({ + type: typeSelect().selectedOptions[0]?.text, + containers: containers(), + typeLinks: typeLinks(), + values: Object.fromEntries( + [...section().querySelectorAll("[data-option-keys]")] + .filter(shown) + .map((n) => [n.dataset.optionKeys, n.querySelector("input, select").value]), + ), + stale: stale(), + }); + const out = { mode: MODE }; + + if (MODE === "seed") { + const now = await api.fetchStructures(projectId); + sessionStorage.setItem("__527_original", JSON.stringify(now.structures)); + const legacy = { + structure_id: null, + type_id: "refuge", + placement: "interval", + chainage_m: 150, + start_m: 145, + end_m: 160, + options: { width_m: 4, length_m: 15 }, + memo: "52-7 옛 대피소", + placement_source: "manual", + status: "draft", + revision: 0, + geometry: null, + }; + await api.saveStructures(projectId, now.revision, [...now.structures, legacy]); + api.writePendingStructures(projectId, null); + const saved = await api.fetchStructures(projectId); + out.seeded = saved.structures.filter((s) => s.type_id === "refuge").length; + out.total = saved.structures.length; + } + + if (MODE === "edit") { + await until(() => section() && groupSelect().options.length > 1); + await choose(groupSelect(), "E"); + out.picker = [...typeSelect().options].filter((o) => o.value).map((o) => o.text); + // 컨테이너 없는 일곱 — 기준 측점 아래 잇기 1 · 컨테이너 0 + out.seven = {}; + for (const id of [ + "mirror", + "curb", + "hazard_sign", + "position_sign", + "water_intake", + "completion_sign", + "barrier_gate", + ]) { + await choose(typeSelect(), id); + out.seven[typeSelect().selectedOptions[0].text] = [containers().length, typeLinks()]; + } + // 대피로 — 상세 내용 컨테이너 · 상세 셋 + await pickType("evac_road"); + await place(7, 0); + out.road = form(); + out.roadDetails = [...opt("detail").options].filter((o) => o.value).map((o) => o.text); + await setOpt("detail", "정차/작업장"); + section().querySelector(".b05-route__irregular-btn.is-primary").click(); + await w(600); + // 가드레일 — 형태 · 길이 10 · 전 3 → 후 7 + await pickType("guardrail"); + await place(5, 0); + await setOpt("form", "W빔 가드레일"); + await setOpt("length_m", "10"); + await setOpt("before_m", "3"); + out.guard = form(); + out.range = section().querySelector(".b05-structure__range-value").textContent; + section().querySelector(".b05-route__irregular-btn.is-primary").click(); + await w(600); + // 옛 대피소 기록 = 대피로 + 상세 대피소 · 옛 칸 값은 지난 정의 값 + await openItem("대피소"); + out.legacy = form(); + section().querySelectorAll(".b05-route__irregular-btn")[2].click(); // [리셋] — 안 고치고 닫음 + await w(300); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + const file = await api.fetchStructures(projectId); + out.file = file.structures + .filter((s) => ["evac_road", "guardrail", "refuge"].includes(s.type_id)) + .map((s) => [s.type_id, s.placement, s.chainage_m, s.start_m, s.end_m, s.options]); + } + + if (MODE === "verify") { + await openItem("가드레일"); + const b05 = form(); + out.guard = b05; + await openItem("대피로"); + out.road = form(); + await openItem("대피소"); + out.legacy = form(); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await openItem("가드레일"); + const b06 = form(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + location.hash = "#/b05-profile"; + await w(3000); + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__527_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + const now = await api.fetchStructures(projectId); + await api.saveStructures(projectId, now.revision, original); + api.writePendingStructures(projectId, null); + sessionStorage.removeItem("__527_original"); + const after = await api.fetchStructures(projectId); + const pick = (list) => JSON.stringify(list.map((s) => [s.type_id, s.chainage_m, s.options])); + out.restored = pick(after.structures) === pick(original); + out.count = after.structures.length; + } + return JSON.stringify(out); +})(); From 355361bdb5dd8b3fd83b8a778602a3fea344be14 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:49:27 +0900 Subject: [PATCH 11/37] =?UTF-8?q?feat(design):=20=EB=85=B8=EB=A9=B4?= =?UTF-8?q?=EA=B3=B5=20=EC=BD=98=ED=81=AC=EB=A6=AC=ED=8A=B8=20=ED=8F=AC?= =?UTF-8?q?=EC=9E=A5=20=EB=AA=85=EC=84=B8=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=C2=B7=20=EA=B8=B0=ED=83=80=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=20=EC=9D=B4=EB=A6=84(=ED=91=9C=2008=20=C2=B7?= =?UTF-8?q?=2052-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 콘크리트 포장 = 기준 측점 + 필수 컨테이너 「상세 내용」(포장사양 고르기 · 높이 m 손 입력) · 구조물 조합 잇기 - 포장사양 선택지는 명세 한 곳(PAVE_SPEC_CHOICES · 지금 콘크리트 하나) · 새 키 pave_spec · height_m - 구간 길이 · 전 · 후는 정의 칸 그대로 · 표에 없는 옛 상세 칸(두께 · 폭 · 확폭 · 줄눈 · 비닐 · 철망)은 화면에서 빼고 지난 정의 값으로 보존 - 일반 구조물(structures.json)도 명세 폼을 옵션 칸 아래에 그림(B05_Profile_UI_Structures_Spec) · 잠금은 옵션 칸을 따름 · [수정] 병합은 명세 키를 다시 씀 - 기타 이름 칸 = 「상세 종류 이름」(정의 판 8 · 이력 판_7) · 목록 이름으로 씀 · 종류 단위 잇기 그대로 - 시험: test_52_10_structure_spec_road · 헬퍼 road · ui_checks/52-10_노면공_기타(B05 · B06 같음 · 되돌림) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- A00_Common/design/design_structure_pipe.ts | 6 +- A00_Common/design/design_structure_road.ts | 45 + B05_Profile/B05_Profile_UI_Structures_Form.ts | 9 + B05_Profile/B05_Profile_UI_Structures_List.ts | 10 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 11 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 8 +- B05_Profile/B05_Profile_UI_Structures_Spec.ts | 88 + .../B05_Profile_UI_Style_Structures.css | 1 + resources/master_template/구조물정의.json | 4 +- .../master_template/구조물정의_이력/판_7.json | 3336 +++++++++++++++++ resources/tester/helper_52_structure_spec.cjs | 38 +- .../tester/test_52_10_structure_spec_road.py | 65 + .../tester/ui_checks/52-10_노면공_기타.js | 215 ++ 13 files changed, 3828 insertions(+), 8 deletions(-) create mode 100644 A00_Common/design/design_structure_road.ts create mode 100644 B05_Profile/B05_Profile_UI_Structures_Spec.ts create mode 100644 resources/master_template/구조물정의_이력/판_7.json create mode 100644 resources/tester/test_52_10_structure_spec_road.py create mode 100644 resources/tester/ui_checks/52-10_노면공_기타.js diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index c72f84c0b..55c1a0cba 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -12,6 +12,7 @@ * ========================================================================== */ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; +import { PAVEMENT_SPEC } from "./design_structure_road"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -184,4 +185,7 @@ export const PIPE_SPEC: StructureSpec = { }; /** 명세로 그리는 종류 — 종류 키 → 명세(표가 오는 대로 더함). */ -export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC }; +export const STRUCTURE_SPECS: Readonly> = { + pipe: PIPE_SPEC, + pavement_concrete: PAVEMENT_SPEC, +}; diff --git a/A00_Common/design/design_structure_road.ts b/A00_Common/design/design_structure_road.ts new file mode 100644 index 000000000..4d1f7af13 --- /dev/null +++ b/A00_Common/design/design_structure_road.ts @@ -0,0 +1,45 @@ +/* ============================================================================= + * design_structure_road.ts + * 노면공 › 콘크리트 포장 명세 — 사용자 구조물 표 08(`docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md`). + * + * 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력. + * 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. + * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { SpecChoice, StructureSpec } from "./design_structure_spec"; + +/** 포장사양 선택지 — 표의 나머지 값은 사용자 답 뒤 여기에만 더함. */ +export const PAVE_SPEC_CHOICES: SpecChoice[] = [{ value: "콘크리트", label: "콘크리트" }]; + +export const PAVEMENT_SPEC: StructureSpec = { + typeId: "pavement_concrete", + containers: [ + { + id: "detail", + title: "상세 내용", + required: true, + link: "gpavedetail", + items: [ + { + id: "spec", + label: "포장사양", + kind: "select", + key: "pave_spec", + choices: PAVE_SPEC_CHOICES, + }, + { id: "height", label: "높이 (m)", kind: "number", key: "height_m", step: 0.05 }, + ], + }, + ], + // 표 08 에 없는 옛 상세 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: [ + "thickness_cm", + "width_m", + "widening_area_m2", + "joint_spacing_m", + "separation_sheet", + "wire_mesh", + ], +}; diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index c0e28f3fc..d2d4889bb 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -28,6 +28,7 @@ import { createFacilityDefinition, type FacilityDefinition, } from "./B05_Profile_UI_Structures_Define_Form"; +import { createStructureSpecForm } from "./B05_Profile_UI_Structures_Spec"; export interface StructuresFormElements { root: HTMLElement; @@ -164,6 +165,13 @@ export function buildStructuresForm(options: { set: groupBoxes.setOne, }); groupBoxes.watch(facilityOptions.paintLinks); + // 일반 구조물 명세 컨테이너(PLAN 52-10 · 노면공 등) — 잇기 · 반영 길은 배관 명세와 같음 + const structureSpec = createStructureSpecForm( + optionRow, + { get: groupBoxes.get, pick: groupBoxes.pick, clear: groupBoxes.clear, set: groupBoxes.setOne }, + options.onFacilityChange, + ); + groupBoxes.watch(structureSpec.paintLinks); const staleBox = document.createElement("fieldset"); staleBox.className = "b05-structure__group b05-structure__stale"; staleBox.hidden = true; @@ -175,6 +183,7 @@ export function buildStructuresForm(options: { summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류) positionDivider, optionRow, + structureSpec.root, facilityOptions.root, staleBox, groupBoxes.root, diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index 6f3d43b25..abb884a5d 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -56,6 +56,12 @@ export function numberDuplicateNames(names: ReadonlyArray): string[] { }); } +/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름). */ +function etcName(structure: StructureInstance): string | null { + const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : ""; + return name || null; +} + export function renderStructureList(params: StructureListParams): void { const { list, typeMap, intervalM, editingId, selectedPipeChainage } = params; list.replaceChildren(); @@ -89,7 +95,9 @@ export function renderStructureList(params: StructureListParams): void { const displayNames = numberDuplicateNames( rows.map((row) => row.kind === "structure" - ? (typeMap.get(row.structure.type_id)?.name ?? row.structure.type_id) + ? (etcName(row.structure) ?? + typeMap.get(row.structure.type_id)?.name ?? + row.structure.type_id) : (typeMap.get(row.pipe.facility)?.name ?? row.pipe.facility), ), ); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index e24155203..16f553da4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -18,6 +18,7 @@ import type { FacilityAttributes, FacilityOptionsForm } from "./B05_Profile_UI_D import type { StationFields } from "./B05_Profile_UI_Structures_Fields"; import { splitPavementRange } from "./B05_Profile_UI_Structures_Pavement"; import { mergeStructureOptions } from "./B05_Profile_UI_Structures_Summary"; +import { readStructureSpec, specRenderedKeys } from "./B05_Profile_UI_Structures_Spec"; import type { GroupBoxes } from "./B05_Profile_UI_Structures_Groups"; import { readGroupItems, @@ -40,6 +41,8 @@ export interface StructuresCommitContext { startFields: StationFields; endFields: StationFields; facilityOptions: FacilityOptionsForm; + /** 옵션 칸 — 짝 명세 폼(52-10)을 찾는 열쇠. */ + optionRow: HTMLElement; optionInputs: Array<{ key: string; required: boolean; @@ -205,7 +208,7 @@ export function readOptions(ctx: StructuresCommitContext): Record entry.key), ...ctx.groupBoxes.shapeKeys()], + [ + ...ctx.optionInputs.map((entry) => entry.key), + ...ctx.groupBoxes.shapeKeys(), + ...specRenderedKeys(type, previous.options), // 명세 폼 키(52-10) — 비운 칸 · 뺀 컨테이너는 지움 + ], records[0].options, ), }; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 97032d397..370fd9c7c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -22,6 +22,7 @@ import { import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { specKeys } from "../A00_Common/design/design_structure_spec"; +import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec"; /** 구조물 명세 컨테이너로 그리는 종류인지 — 그때 종류 단위 「구조물 조합」 줄은 가림(PLAN 52-1). */ export const hasSpecContainers = (type: StructureType): boolean => @@ -61,10 +62,15 @@ export function renderOptionFields( optionRow.replaceChildren(); // 서브폼이 담당하는 타입(계곡 통과 시설·독립 기슭막이)은 여기서 그리지 않는다. // 차례 · 안 씀 · 숨김은 정의대로(48-3 · `_Structures_Define`). + // 명세 종류(52-10)는 명세 키 · 명세가 뺀 옛 키를 칸으로 안 그림 — 명세 폼이 옵션 칸 아래에 그림 + const owned = specOwnedKeys(type); const shown = type && !ctx.facilityFormKind(type) - ? orderedOptions(type).filter((o) => ctx.includeDetail || isB05Option(o)) + ? orderedOptions(type).filter( + (o) => (ctx.includeDetail || isB05Option(o)) && !owned.has(o.key), + ) : []; + paintStructureSpec(optionRow, type, values); optionRow.style.gridTemplateColumns = ""; if (!type || !shown.length) { optionRow.hidden = true; diff --git a/B05_Profile/B05_Profile_UI_Structures_Spec.ts b/B05_Profile/B05_Profile_UI_Structures_Spec.ts new file mode 100644 index 000000000..d63ecab14 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Spec.ts @@ -0,0 +1,88 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Spec.ts + * 일반 구조물(structures.json) 명세 컨테이너 폼(PLAN 52-10) — 계곡 통과 시설 밖 종류가 명세 + * (`STRUCTURE_SPECS`)를 가지면 옵션 칸 아래에 같은 공용 폼(`design_structure_form`)을 그림. + * + * · 폼은 옵션 칸(`optionRow`) 짝으로 하나 — 조립기가 세워 걸고, 옵션 그리기 · 저장이 꺼내 씀 + * · 명세 키 · 명세가 뺀 옛 키(retired)는 정의 옵션 칸으로 그리지 않음(옛 값은 병합이 지킴) + * · 잠금은 옵션 칸을 따름(측점 확정 전 흐림 · 조작 막음) + * ========================================================================== */ + +import type { StructureType } from "./B05_Profile_Api_Structures"; +import { + createSpecForm, + type SpecForm, + type SpecLinks, +} from "../A00_Common/design/design_structure_form"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { specKeys, type StructureSpec } from "../A00_Common/design/design_structure_spec"; + +const forms = new WeakMap(); + +/** 일반 구조물 명세 — 계곡 통과 시설(관 지점 정본)은 시설 폼 몫이라 뺌. */ +export const structureSpecOf = (type: StructureType | null): StructureSpec | null => + (type && !type.managed_by && STRUCTURE_SPECS[type.type_id]) || null; + +/** 옵션 칸 짝 폼을 세움 — 값이 바뀌면 `onChange`(패널 즉시 반영). */ +export function createStructureSpecForm( + optionRow: HTMLElement, + links: SpecLinks, + onChange: () => void, +): SpecForm { + const form = createSpecForm(links, { onChange }); + form.root.hidden = true; + forms.set(optionRow, form); + const lock = (): void => { + const locked = optionRow.classList.contains("is-locked"); + form.root.inert = locked; + form.root.classList.toggle("is-locked", locked); + }; + if (typeof MutationObserver !== "undefined") + new MutationObserver(lock).observe(optionRow, { + attributes: true, + attributeFilter: ["class"], + }); + return form; +} + +/** 정의 옵션 칸으로 안 그리는 키 — 명세 키 · 명세가 뺀 옛 키. */ +export function specOwnedKeys(type: StructureType | null): Set { + const spec = structureSpecOf(type); + return new Set(spec ? [...specKeys(spec).keys, ...spec.retired] : []); +} + +/** 종류 · 값으로 명세 폼을 다시 그림(명세 없는 종류면 가림). */ +export function paintStructureSpec( + optionRow: HTMLElement, + type: StructureType | null, + values: Record, +): void { + const form = forms.get(optionRow); + const spec = structureSpecOf(type); + if (!form) return; + form.root.hidden = !spec; + if (!spec) return; + const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + form.write(spec, values, type?.spec_names ?? {}, groups); +} + +/** 명세 폼 값(저장 키 그대로) — 명세 없는 종류면 빈 묶음. */ +export function readStructureSpec( + optionRow: HTMLElement, + type: StructureType | null, +): Record { + const form = forms.get(optionRow); + return form && structureSpecOf(type) ? form.read() : {}; +} + +/** [수정] 병합에서 폼이 다시 쓰는 키 — 명세 키 + 옛 값 중 선택 컨테이너 머리 키(빼면 지워져야 함). */ +export function specRenderedKeys( + type: StructureType | null, + previous: Record | undefined, +): string[] { + const spec = structureSpecOf(type); + if (!spec) return []; + const { keys, prefixes } = specKeys(spec); + const own = Object.keys(previous ?? {}).filter((key) => prefixes.some((p) => key.startsWith(p))); + return [...keys, ...own]; +} diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index bb609526d..94df817ee 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -190,6 +190,7 @@ /* 측점 미입력 상태의 상세 옵션 잠금 — 입력 순서 가이드(2026-08-18 사용자 지시). 조작 차단은 disabled·inert가 맡고 여기서는 흐림만 그린다. */ .b05-structure__grid.is-locked, +.b05-structure__spec.is-locked, .b05-drainage__facility.is-locked { opacity: 0.5; } diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 6694fae47..7e647d948 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 7, + "판": 8, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -3196,7 +3196,7 @@ "options": [ { "key": "name", - "label": "이름", + "label": "상세 종류 이름", "input": "text", "default": "기타 구조물" } diff --git a/resources/master_template/구조물정의_이력/판_7.json b/resources/master_template/구조물정의_이력/판_7.json new file mode 100644 index 000000000..6694fae47 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_7.json @@ -0,0 +1,3336 @@ +{ + "판": 7, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": null, + "required": false + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "콘크리트", + "돌(찰)", + "돌(메)", + "블록쌓기", + "돌망태", + "흙포대", + "통나무쌓기", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(구 사토장)", + "placement": "interval", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "쌓는 쪽", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "required": false, + "phase": "b05", + "default_basis": "「자동(성토 쪽)」 — 흙은 성토 쪽에 쌓는다는 지금까지의 동작을 이어받는 표식. 측점마다 좌·우로 바꿈" + }, + { + "key": "fill_slope_ratio", + "label": "비탈 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 그 측점의 노선 성토 기울기를 그대로 씀 — 지식DB 가 「기본 비탈기울기는 근거에 없다」고 못 박은 자리" + }, + { + "key": "extra_distance_m", + "label": "추가 운반거리", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "detail", + "default_basis": "0 = 「추가 없음」 — 노선 안 사토장은 측점 누가거리로 거리가 나오므로 이 칸은 외부 사토장 대비 여벌" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "capacity_m3", + "label": "용량", + "input": "number", + "unit": "㎥", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.structure": "구조", + "soil_add.structure=기슭막이": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ] + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ] + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": { + "pipe": { + "pipe_kind": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index 0ff82723b..b99ddd52d 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -17,7 +17,43 @@ const load = (file, deps = {}) => { return box.exports; }; const s = load("design_structure_spec.ts"); -const { PIPE_SPEC } = load("design_structure_pipe.ts"); +const road = load("design_structure_road.ts"); +const { PIPE_SPEC, STRUCTURE_SPECS } = load("design_structure_pipe.ts", { + "./design_structure_road": road, +}); + +// 노면공 › 콘크리트 포장(PLAN 52-10) — 옛 상세 칸은 남고 명세 키만 오감 +if (process.argv[2] === "road") { + const spec = STRUCTURE_SPECS.pavement_concrete; + const old = { + length_m: 12, + before_m: 4, + after_m: 8, + thickness_cm: 20, + joint_spacing_m: 6, + pave_spec: "콘크리트", + height_m: 0.2, + }; + const state = s.readState(spec, old); + const edited = JSON.parse(JSON.stringify(state)); + edited.detail[0].height = "0.35"; + const cleared = JSON.parse(JSON.stringify(state)); + cleared.detail[0].height = ""; + process.stdout.write( + JSON.stringify({ + registered: Object.keys(STRUCTURE_SPECS), + same: spec === road.PAVEMENT_SPEC, + containers: spec.containers.map((c) => [c.id, c.title, c.required, c.link]), + choices: road.PAVE_SPEC_CHOICES.map((c) => c.value), + state, + edited: s.writeOptions(spec, edited), + cleared: s.writeOptions(spec, cleared), + keys: s.specKeys(spec), + retired: spec.retired, + }), + ); + process.exit(0); +} if (process.argv[2] === "names") { process.stdout.write(JSON.stringify(s.nameTable(PIPE_SPEC))); diff --git a/resources/tester/test_52_10_structure_spec_road.py b/resources/tester/test_52_10_structure_spec_road.py new file mode 100644 index 000000000..2cdd9376d --- /dev/null +++ b/resources/tester/test_52_10_structure_spec_road.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +"""노면공 › 콘크리트 포장 명세 · 기타 이름(사용자 구조물 표 08) — PLAN 52-10. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs road`)가 돌림 · +화면은 ORCA `ui_checks/52-10_노면공_기타.js`. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +DEFINITION = os.path.join(ROOT, "resources", "master_template", "구조물정의.json") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), "road"], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_명세_등록_필수_컨테이너_하나(out): + assert out["registered"] == ["pipe", "pavement_concrete"] + assert out["same"] is True + assert out["containers"] == [["detail", "상세 내용", True, "gpavedetail"]] + + +def test_포장사양_선택지는_명세_한_곳(out): + assert out["choices"] == ["콘크리트"] + + +def test_저장_키_왕복_옛_칸은_명세_밖(out): + assert out["state"] == {"detail": [{"spec": "콘크리트", "height": "0.2"}]} + assert out["edited"] == {"pave_spec": "콘크리트", "height_m": 0.35} + assert out["cleared"] == {"pave_spec": "콘크리트"} + assert out["keys"] == {"keys": ["pave_spec", "height_m"], "prefixes": []} + assert "length_m" not in out["retired"] # 구간 길이 · 전 · 후는 정의 칸 그대로 + assert set(out["retired"]) == { + "thickness_cm", + "width_m", + "widening_area_m2", + "joint_spacing_m", + "separation_sheet", + "wire_mesh", + } + + +def test_기타_상세_종류_이름_글_칸(): + with open(DEFINITION, encoding="utf-8") as f: + doc = json.load(f) + etc = next(t for t in doc["types"] if t["type_id"] == "etc") + name = next(o for o in etc["options"] if o["key"] == "name") + assert name["label"] == "상세 종류 이름" and name["input"] == "text" + pave = next(t for t in doc["types"] if t["type_id"] == "pavement_concrete") + assert [o["key"] for o in pave["options"]][:3] == ["length_m", "before_m", "after_m"] diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js new file mode 100644 index 000000000..4ec66b9e8 --- /dev/null +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -0,0 +1,215 @@ +/* 52-10 — B05 · B06 좌측 「구조물 배치」 노면공 › 콘크리트 포장 · 기타 = 사용자 구조물 표 08 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(데스크탑_보조) · 폼 비어 있음 · 로그인 상태 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5210 = "단계";` 를 붙여 + * orca eval --expression "window.__m5210='edit'; $(cat 이 파일)" → 두 종류 모양 · 값 · 잇기 · [추가] · [저장] + * (orca reload 로 다시 열기) + * orca eval --expression "window.__m5210='verify'; $(cat 이 파일)" → 저장 값 그대로 · 같은 모양 + * (B06 으로 옮겨 같은 단계 — 같은 값 · 같은 모양) + * orca eval --expression "window.__m5210='restore'; $(cat 이 파일)" → 추가한 둘 삭제 · [저장] + * 쓰는 것: 검증 프로젝트 structures.json(되돌림 단계가 지움) + * 기대 수치: + * 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 ["콘크리트"] · 높이 0.35 · 잇기 지정 · + * 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · 길이 · 전 · 후 칸 보임 + * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · 종류 단위 잇기 줄 보임 · 지정 + * verify — 같은 값 · 같은 모양(B05 = B06) + */ +(async () => { + const MODE = window.__m5210 || "edit"; + const NAME = "52-10 시험 기타"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const vis = (n) => !!n && n.offsetParent !== null; + const sec = () => document.querySelector(".b05-structure-section"); + const body = () => sec().querySelector(".b05-route__panel-body"); + const spec = () => sec().querySelector(".b05-structure__spec"); + const selects = () => sec().querySelectorAll(".b05-structure__grid select"); + const fire = (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const ITEMS = [ + { typeId: "pavement_concrete", group: "G", at: [4, 6], list: /콘크리트 포장/ }, + { typeId: "etc", group: "호환", at: [5, 2], list: new RegExp(NAME) }, + ]; + const typeSummary = () => + [...body().children].find((c) => c.classList.contains("b05-structure__summary")); + const optionRow = () => + [...body().children].filter((c) => c.classList.contains("b05-structure__grid"))[1]; + const optionLabels = () => + vis(optionRow()) + ? [...optionRow().querySelectorAll(":scope > label > span:first-child")].map((s) => + s.textContent.trim(), + ) + : []; + const specCtl = (item) => + spec()?.querySelector(`[data-spec-item="detail.${item}"]`)?.querySelector("select, input"); + + const shape = (typeId) => { + const position = [...body().children].find((c) => + c.classList.contains("b05-structure__position-row"), + ); + const after = [...body().children] + .slice([...body().children].indexOf(position) + 1) + .find((c) => vis(c)); + const nameInput = [...optionRow().querySelectorAll("label")] + .find((l) => l.textContent.includes("상세 종류 이름")) + ?.querySelector("input"); + const boxes = vis(spec()) + ? [...spec().querySelectorAll("[data-spec-container]")].filter(vis) + : []; + return { + typeId, + options: optionLabels(), + containers: boxes.map((b) => b.dataset.specContainer), + legend: boxes.map((b) => b.querySelector("legend").firstChild.textContent), + paveChoices: specCtl("spec") + ? [...specCtl("spec").options].filter((o) => o.value).map((o) => o.value) + : null, + paveSpec: specCtl("spec")?.value ?? null, + height: specCtl("height")?.value ?? null, + containerLink: !!boxes[0]?.querySelector(".b05-structure__summary-name.is-assigned"), + typeSummaryVisible: vis(typeSummary()), + typeSummaryAfterPosition: after === typeSummary(), + typeLink: !!typeSummary().querySelector(".b05-structure__summary-name.is-assigned"), + name: nameInput?.value ?? null, + stale: vis(sec().querySelector(".b05-structure__stale")), + locked: !!spec()?.inert, + }; + }; + const pickType = async (group, typeId) => { + const [g, t] = selects(); + fire(g, group); + await w(300); + fire(t, typeId); + await w(500); + }; + const linkFirstLeaf = async (button) => { + button.click(); + await until(() => document.querySelector(".ui-item-picker")); + await w(600); + const modal = document.querySelector(".ui-item-picker"); + const ctl = ["닫기", "개인용으로 복사", "관리 화면에서 고치기", "해제", "적용"]; + const leaf = [...modal.querySelectorAll("button")].find((b) => { + const t = b.textContent.trim(); + return t && !/^[▸▾]/.test(t) && !ctl.includes(t); + }); + leaf?.click(); + await w(300); + [...modal.querySelectorAll("button")].find((b) => b.textContent.trim() === "적용")?.click(); + await w(500); + return !!leaf; + }; + const clickBtn = async (text) => { + [...document.querySelectorAll("button")] + .find((b) => b.textContent.trim() === text && vis(b)) + ?.click(); + await w(1500); + }; + // B06 은 첫 눌림이 그 측점의 다른 항목(관 등)을 고르기도 함 — 목록이 다시 그려지므로 새로 찾아 한 번 더 + const open = async (item) => { + for (let k = 0; k < 3 && selects()[1].value !== item.typeId; k++) { + const li = listItems().find((l) => item.list.test(l.textContent)); + if (!li) return false; + (li.querySelector("button") ?? li).click(); + await w(800); + } + return selects()[1].value === item.typeId; + }; + const out = { mode: MODE, items: [] }; + + if (MODE === "edit") { + await until(() => listItems().length > 0); + out.before = listItems().length; + for (const item of ITEMS) { + await pickType(item.group, item.typeId); + const row = { lockedBeforePosition: shape(item.typeId).locked }; + const [no, dist] = sec() + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input"); + fire(no, String(item.at[0])); + fire(dist, String(item.at[1])); + await w(300); + if (item.typeId === "pavement_concrete") { + fire(specCtl("spec"), "콘크리트"); + await w(200); + fire(specCtl("height"), "0.35"); + await w(200); + row.picked = await linkFirstLeaf( + spec().querySelector('[data-spec-link] [data-b05-summary="pick"]'), + ); + } else { + const input = [...optionRow().querySelectorAll("label")] + .find((l) => l.textContent.includes("상세 종류 이름")) + .querySelector("input"); + input.value = NAME; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + row.picked = await linkFirstLeaf(typeSummary().querySelector('[data-b05-summary="pick"]')); + } + Object.assign(row, shape(item.typeId), { phase: "edit" }); + sec().querySelector(".is-primary").click(); // 추가 + await w(800); + out.items.push(row); + } + out.after = listItems().length; + await clickBtn("저장"); + out.listed = listItems() + .map((l) => l.textContent.trim()) + .filter((t) => /콘크리트 포장|52-10/.test(t)); + } else if (MODE === "verify") { + await until(() => listItems().length > 0); + for (const item of ITEMS) { + if (!(await open(item))) { + out.items.push({ typeId: item.typeId, missing: true }); + continue; + } + await w(300); + out.items.push({ ...shape(item.typeId), phase: "verify" }); + const li = listItems().find((l) => item.list.test(l.textContent)); + (li.querySelector("button") ?? li).click(); // 재선택 = 해제 + await w(300); + } + out.listed = listItems() + .map((l) => l.textContent.trim()) + .filter((t) => /콘크리트 포장|52-10/.test(t)); + } else if (MODE === "restore") { + await until(() => listItems().length > 0); + for (const item of ITEMS) { + if (!(await open(item))) continue; + await clickBtn("삭제"); + } + await clickBtn("저장"); + out.left = listItems().length; + out.leftover = listItems().filter((l) => /52-10/.test(l.textContent)).length; + } + const [pave, etc] = out.items; + out.paveOk = + !!pave && + !pave.missing && + JSON.stringify(pave.containers) === '["detail"]' && + pave.legend[0] === "상세 내용" && + JSON.stringify(pave.paveChoices) === '["콘크리트"]' && + pave.paveSpec === "콘크리트" && + Number(pave.height) === 0.35 && + pave.containerLink && + !pave.typeSummaryVisible && + !pave.options.some((l) => /두께|줄눈|철망|비닐|포장 폭|확폭/.test(l)) && + ["길이", "기준측점 전", "기준측점 후"].every((l) => pave.options.some((o) => o.startsWith(l))); + out.etcOk = + !!etc && + !etc.missing && + etc.name === NAME && + etc.containers.length === 0 && + etc.typeSummaryVisible && + etc.typeSummaryAfterPosition && + etc.typeLink; + return JSON.stringify(out); +})(); From 0a99e84bd7311ea0c4563c2e4e85cefad61e15c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:49:55 +0900 Subject: [PATCH 12/37] =?UTF-8?q?chore(docs):=2010-5=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=88=98=EC=A0=95=EC=9A=94=EA=B5=AC=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20=E2=80=94=20=EB=B2=94=EC=9C=84=20=EC=A7=80=EC=8B=9C?= =?UTF-8?q?=20=EB=91=90=20=EC=A4=84(=EB=A9=94=EC=9D=B8=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EB=82=B4=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index bebbfa64f..81999c1c9 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -119,3 +119,6 @@ 추가로 구조물 조합의 일위대가 상세페이지의 로직 테이블의 순서 열에서 존재하는 버튼을 2행 구조로 변경 단가 승격에도 옵션 버튼이있어야할것같고. 일위조합의 경우에는 바로 옵션으로 지정할수 없으니 수정버튼(모달)도 잇어할것 같은데? + +맹암거처럼 cad연결이나 횡단도에 그릴지 말지는 이번 수정 후에 작업할거야. 너는 B05/B06 좌측 패널의 생성과 기존 로직 연결에만 신경쓸것. +나는 이제 자러 갈거니 너가 다른 워크트리들을 잘 제어해서 계획서의 내용을 반영하고 최종검증을 진행 후 아티팩트로 결과를 보여주는데 만약 개선이 필요하다고 판단되는 부분이 있다면 계획서에 내용을 추가해서 개선할것. From aac85be021840b67f6ea0fcc15c3ad26c945d628 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:51:05 +0900 Subject: [PATCH 13/37] =?UTF-8?q?chore(plan):=2010-5=20=EB=B0=A4=20?= =?UTF-8?q?=EB=B2=94=EC=9C=84=20=EC=A7=80=EC=8B=9C=20=EC=9B=90=EB=AC=B8=20?= =?UTF-8?q?(=EB=A9=94=EC=9D=B8=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EB=82=B4?= =?UTF-8?q?=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index bebbfa64f..81999c1c9 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -119,3 +119,6 @@ 추가로 구조물 조합의 일위대가 상세페이지의 로직 테이블의 순서 열에서 존재하는 버튼을 2행 구조로 변경 단가 승격에도 옵션 버튼이있어야할것같고. 일위조합의 경우에는 바로 옵션으로 지정할수 없으니 수정버튼(모달)도 잇어할것 같은데? + +맹암거처럼 cad연결이나 횡단도에 그릴지 말지는 이번 수정 후에 작업할거야. 너는 B05/B06 좌측 패널의 생성과 기존 로직 연결에만 신경쓸것. +나는 이제 자러 갈거니 너가 다른 워크트리들을 잘 제어해서 계획서의 내용을 반영하고 최종검증을 진행 후 아티팩트로 결과를 보여주는데 만약 개선이 필요하다고 판단되는 부분이 있다면 계획서에 내용을 추가해서 개선할것. From 7e6d3fa500c0a5a77edeec718fc718000e3b7f1f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:51:11 +0900 Subject: [PATCH 14/37] =?UTF-8?q?feat(M02):=20=EB=B0=B0=EA=B4=80=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88?= =?UTF-8?q?=EB=A7=88=EB=8B=A4=20=EC=A7=91=EA=B3=84=ED=91=9C=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=20(PLAN=2052-1b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 흙막이 추가 · 도수로/돌붙임 인스턴스마다 한 건 — 잇기 group_items[{link}{n}] 항목 · 그 컨테이너 값(형태 · 길이 · 높이 · 전 · 후를 열이 읽는 칸에 얹음) · 복사본 pipe@측점__{link}{n} - 옛 값 — 컨테이너 없는 옛 관 · 인스턴스 없는 잇기는 줄 없음 - 서버 컨테이너 표 = 배관 명세 TS 짝(시험이 대조) · 700줄 한계로 M02_Table_Fill_Spec 따로 - 시험 test_52_1b_pipe_links · 52-1 화면 시나리오에 집계표 줄 수 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- M02_MasterTemplete/M02_Table_Fill.py | 2 + M02_MasterTemplete/M02_Table_Fill_Spec.py | 63 ++++++++++++++ resources/tester/test_52_1b_pipe_links.py | 84 +++++++++++++++++++ .../tester/ui_checks/52-1_배관_컨테이너.js | 21 ++++- 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 M02_MasterTemplete/M02_Table_Fill_Spec.py create mode 100644 resources/tester/test_52_1b_pipe_links.py diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index a55dc4341..1021a017c 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -138,6 +138,7 @@ def _with_group_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: 복사본 `{구조물 id}__{그룹 id}`. 정의된 그룹만(미지정 그룹 = 「미지정」 알림에 셈) · 정의가 사라진 그룹 연결은 기록에만 남고 표에 안 들어감.""" from common_util.common_util_summary_item import GROUP_JOIN + from M02_MasterTemplete.M02_Table_Fill_Spec import container_rows from M02_MasterTemplete.StructOptions.M02_SO_Groups import groups_of defined = groups_of() @@ -154,6 +155,7 @@ def _with_group_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]: "그룹": group["name"], } ) + out.extend(container_rows(item, links)) # 선택 컨테이너마다 한 건(PLAN 52-1b) return out diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py new file mode 100644 index 000000000..2de0d16c3 --- /dev/null +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -0,0 +1,63 @@ +"""집계표 채움 — 구조물 명세 선택 컨테이너마다 한 건 더(PLAN 52-1b). + +선택 컨테이너 인스턴스(옵션 `{머리}_{n}_{항목}`)마다 같은 구조물 · 자리에 한 건 — +집계 항목 = 잇기 `group_items[{잇기}{n}]` · 복사본 `{구조물 id}__{잇기}{n}` · 옵션 = 그 컨테이너 값. +열 바인딩이 읽는 칸이 갈래마다 달라(독립 기슭막이 `form` · `length_m` … · 관 `{유입|유출}_revet_*`) +컨테이너 값을 그 칸들에 함께 얹음 — 어느 출처로 읽어도 그 컨테이너 값. +짝: `A00_Common/design/design_structure_pipe.ts` 선택 컨테이너(머리 · 잇기 · 제목) — 시험이 대조. +""" + +from __future__ import annotations + +import re +from typing import Any + +from common_util.common_util_summary_item import GROUP_JOIN + +#: 종류 → 선택 컨테이너 (머리, 잇기, 제목) +SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { + "pipe": ( + ("soil_add", "gsoiladd", "흙막이 추가"), + ("chute_add", "gchuteadd", "도수로/돌붙임"), + ), +} +_TEXT = ("structure", "form") + + +def _as_options(values: dict[str, Any]) -> dict[str, Any]: + """컨테이너 값 → 열이 읽는 칸 — 구조 = `structure` · `{쪽}_type` · + 나머지 = 맨 칸 + `{쪽}_revet_*`.""" + out: dict[str, Any] = {} + for item, value in values.items(): + if item == "structure": + out.update(structure=value, inlet_type=value, outlet_type=value) + continue + name = item if item in _TEXT else f"{item}_m" + out[name] = value + for side in ("inlet", "outlet"): + out[f"{side}_revet_{name}"] = value + return out + + +def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str, Any]]: + """한 구조물의 선택 컨테이너 인스턴스마다 한 건(n 차례) — 컨테이너 없는 종류는 빈 목록.""" + rows: list[dict[str, Any]] = [] + for prefix, link, title in SPEC_CONTAINERS.get(item["type_id"], ()): + head = re.compile(rf"^{prefix}_(\d+)_(.+)$") + found: dict[int, dict[str, Any]] = {} + for key, value in item["options"].items(): + hit = head.match(key) + if hit: + found.setdefault(int(hit.group(1)), {})[hit.group(2)] = value + for n in sorted(found): + gid = f"{link}{n}" + rows.append( + { + **item, + "options": {**item["options"], **_as_options(found[n])}, + "summary_item": links.get(gid), + "복사본": f"{item['복사본']}{GROUP_JOIN}{gid}", + "그룹": f"{title} {n}", + } + ) + return rows diff --git a/resources/tester/test_52_1b_pipe_links.py b/resources/tester/test_52_1b_pipe_links.py new file mode 100644 index 000000000..c0cc03f99 --- /dev/null +++ b/resources/tester/test_52_1b_pipe_links.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""배관 남은 연동 — PLAN 52-1b. + +못박는 것 + - 집계표: 선택 컨테이너(흙막이 추가 · 도수로/돌붙임)마다 한 건 — 잇기 `{link}{n}` 항목 · + 그 컨테이너 값. + - 짝: 서버 컨테이너 표(머리 · 잇기) = 배관 명세 TS. +""" + +from __future__ import annotations + +import json +import os +import re + +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete.M02_Table_Fill_Spec import SPEC_CONTAINERS, container_rows +from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물 + MS, + PIPE, + RV, + _pipe, + _save, + world, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) +SOIL = { + "soil_add_1_structure": "기슭막이", + "soil_add_1_form": "바자", + "soil_add_1_length": 6, + "soil_add_1_height": 2, + "soil_add_2_structure": "기슭막이", + "chute_add_1_structure": "돌붙임", +} + + +def test_컨테이너마다_한_건_잇기와_그_값(): + item = {"type_id": "pipe", "options": {"pipe_diameter_mm": 1000, **SOIL}, "복사본": "pipe@40.0"} + rows = container_rows(item, {"gsoiladd1": RV, "gchuteadd1": MS}) + assert [r["그룹"] for r in rows] == ["흙막이 추가 1", "흙막이 추가 2", "도수로/돌붙임 1"] + assert [r["복사본"] for r in rows] == [ + "pipe@40.0__gsoiladd1", + "pipe@40.0__gsoiladd2", + "pipe@40.0__gchuteadd1", + ] + assert [r["summary_item"] for r in rows] == [RV, None, MS] + first = rows[0]["options"] + assert first["length_m"] == first["inlet_revet_length_m"] == first["outlet_revet_length_m"] == 6 + assert ( + first["form"] == first["outlet_revet_form"] == "바자" and first["outlet_type"] == "기슭막이" + ) + assert first["pipe_diameter_mm"] == 1000 # 구조물 옵션은 그대로 + assert container_rows({"type_id": "masonry_dry", "options": SOIL, "복사본": "st0"}, {}) == [] + # 옛 값 — 컨테이너 없는 옛 관 · 인스턴스 없는 잇기는 줄 없음 + old = {"type_id": "pipe", "options": {"inlet_revet_length_m": 5}, "복사본": "pipe@9.0"} + assert container_rows(old, {"gsoiladd9": RV}) == [] + + +def test_집계표_채움에_컨테이너_줄(world): + """흙막이 추가 1 → 돌기슭막이 열(rv) 6 m · 관 본체 · 미지정 컨테이너는 표에 안 듦.""" + root, system = world["root"], world["system"] + point = {**_pipe(PIPE), "group_items": {"gsoiladd1": RV}} + point["options"] = {**point["options"], **SOIL} + _save(root, [], [point]) + table = json.loads((system / "table/구조물집계표.json").read_text("utf-8")) + doc = fill.fill_table(root, table) + values = [row["값"] for row in doc["줄"] if not row.get("고정")] + rv = [v for row in values for k, v in row.items() if k.split("|")[0] == "rv"] + assert rv == [6] + copies = [i["복사본"] for i in fill.collect_items(root)] + assert "pipe@40.0__gsoiladd1" in copies and "pipe@40.0__gchuteadd1" in copies + + +def test_서버_컨테이너_표는_배관_명세와_짝(): + with open( + os.path.join(ROOT, "A00_Common/design/design_structure_pipe.ts"), encoding="utf-8" + ) as f: + text = f.read() + found = re.findall( + r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text + ) + assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["pipe"]) diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index 02840470e..8d0a7cc51 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -9,6 +9,8 @@ * 기대 수치(edit): kinds 5 · required 3 늘 보임 · formBasin I형/L형/ㄷ형 · formSoil 6 · split 길이 7 · * soilAdd 둘 → 하나 빼면 하나(콘크리트) · chute 하나(돌붙임) · 잇기 유입 지정 · 유출 지정 뒤 풂 * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) · 관 길이 수(B06) + * 52-1b: edit 가 흙막이 추가 · 도수로/돌붙임 컨테이너도 이음 → verify 집계표 줄 = 처음 + 3 + * (유입 잇기 1 · 컨테이너마다 1) */ (async () => { const MODE = window.__m521 || "edit"; @@ -28,6 +30,15 @@ .filter((n) => n.includes(part)) .pop(); const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const m02Api = await import( + modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts").replace( + /B04_PreProcess\/B04_PreProcess_Api_Fetch\.ts.*$/, + "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts", + ) + ); + /** 구조물집계표 채움 줄 수(전구간 고정 줄 뺌) — 52-1b. */ + const filledRows = async () => + (await m02Api.readFilled(projectId, "구조물집계표")).문서.줄.filter((r) => !r.고정).length; const spec = () => document.querySelector(".b05-structure__spec"); const box = (c, i = 0) => spec().querySelectorAll(`[data-spec-container="${c}"]`)[i]; const ctl = (c, item, i = 0) => @@ -86,6 +97,8 @@ if (MODE === "edit") { const before = await fetchApi.fetchDetailPipePoints(projectId); sessionStorage.setItem("__521_original", JSON.stringify(before.pipe_points)); + out.baseRows = await filledRows(); + sessionStorage.setItem("__521_baseRows", String(out.baseRows)); await pickPipe(); const s0 = shape(); out.kinds = @@ -128,6 +141,10 @@ await w(200); await set("chute_add", "structure", "돌붙임"); out.chuteChoices = labels("chute_add", "structure"); + // 52-1b — 컨테이너마다 구조물 조합 잇기(집계표 한 줄씩) + await pickLink("soil_add"); + await pickLink("chute_add"); + out.linkContainers = [linkName("soil_add"), linkName("chute_add")]; // 구조물 조합 잇기 — 유입 지정 · 유출 지정 뒤 풂 await pickLink("inlet"); out.linkInlet = linkName("inlet"); @@ -169,6 +186,8 @@ out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); if (!out.sameDrawing) out.diff = { b05, b06 }; out.b06Length = ctl("pipe", "length").value; + out.baseRows = Number(sessionStorage.getItem("__521_baseRows")); + out.rows = await filledRows(); } if (MODE === "restore") { @@ -191,7 +210,7 @@ ); await fetchApi.saveDetailPipePoints(projectId, points); for (const k of Object.keys(sessionStorage)) - if (k.startsWith("aislo:draft:pipes:") || k === "__521_original") + if (k.startsWith("aislo:draft:pipes:") || k.startsWith("__521_")) sessionStorage.removeItem(k); const now = await fetchApi.fetchDetailPipePoints(projectId); const pick = (list) => From 2471a14a6253c5eb79010ccfc81a22bc28cecf1a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 23:59:41 +0900 Subject: [PATCH 15/37] =?UTF-8?q?feat(M02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EC=A0=95=EC=9D=98=20=3D=20=EC=A0=95?= =?UTF-8?q?=EC=9D=98=20=EA=B7=B8=EB=A3=B9=20=EA=BC=B4=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?=EB=8F=84=20=EC=9D=B4=EB=A6=84=20=EB=82=98=EB=AC=B4=20=C2=B7=20?= =?UTF-8?q?=EC=98=9B=20=ED=8E=B8=EC=A7=91=EA=B8=B0=20=EB=AA=A8=EB=93=A0=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98=EC=97=90=EC=84=9C=20=EC=88=A8=EA=B9=80(52-12?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세 없는 종류 = 그룹 제목(그룹.이름) › 칸 이름(옵션 label) › 선택지 이름(종류.이름 `{옵션}={값}` · 값 그대로) · 그룹 밖 칸은 머리 줄 아래 - 칸 더하기 · 빼기 · 꼴 · 필수는 읽기만 · 칸 없는 종류(개거 · 노출형 횡단수로)는 「고칠 이름 없음」 · 종류 이름은 머리 칸 - 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점)는 OLD_EDITOR 거짓으로 숨김(코드 그대로) - 미리보기 = B05 칸 부품(field · optionControl · 그룹 상자)으로 같은 차례 - B05 · B06 고르기 칸 보기 글 = 정의 이름 표(값 그대로 · 규칙 선택지 · 시설 폼 덧보기 포함) - 시험: test_m02_so_def_spec 정의 그룹 꼴 더함 · 헬퍼가 이웃 TS 를 읽게 고침 · ui_checks/52-11 에 맹암거 · 바닥막이 · 개거 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- .../B05_Profile_UI_Structures_Define_Form.ts | 3 +- .../B05_Profile_UI_Structures_Fields.ts | 4 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 17 ++- .../StructOptions/M02_SO_Def.ts | 9 +- .../StructOptions/M02_SO_Def_Spec.ts | 80 +++++++++-- .../StructOptions/M02_SO_Def_Spec_Model.ts | 74 +++++++++- .../StructOptions/M02_SO_Text.ts | 6 +- resources/tester/helper_m02_so_def_spec.cjs | 37 ++++- resources/tester/test_m02_so_def_spec.py | 16 +++ .../tester/ui_checks/52-11_정의_이름고치기.js | 127 +++++++++++++++++- 10 files changed, 335 insertions(+), 38 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts index 7f321904a..bce554803 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Define_Form.ts @@ -48,7 +48,8 @@ export function createFacilityDefinition(root: HTMLElement): FacilityDefinition // 정의에 새로 든 선택지는 보기를 더하고, 정의 · 규칙 밖 보기는 가림(빈 보기 · 지금 값은 그대로) const have = new Set([...control.options].map((item) => item.value)); for (const choice of state.choices) - if (!have.has(choice)) control.add(new Option(choice, choice)); + if (!have.has(choice)) + control.add(new Option(type.spec_names?.[`${option.key}=${choice}`] ?? choice, choice)); for (const item of control.options) { item.hidden = !!item.value && !state.choices.includes(item.value) && item.value !== control.value; diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index ed31f4ce4..eaf354609 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -68,6 +68,8 @@ interface OptionShape { export function optionControl( option: OptionShape, stored: string | number | undefined, + /** 선택지 보일 글(정의 이름 표 · 52-12) — 값은 그대로 */ + label: (choice: string) => string = (c) => c, ): HTMLInputElement | HTMLSelectElement { const value = stored === undefined || stored === null ? "" : String(stored); // 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻. @@ -78,7 +80,7 @@ export function optionControl( const kept = value && !option.choices.includes(value) ? [value] : []; const element = select([ ["", blank], - ...[...option.choices, ...kept].map((c) => [c, c] as [string, string]), + ...[...option.choices, ...kept].map((c) => [c, label(c)] as [string, string]), ]); element.value = value; if (tip) element.title = tip; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index af789a5dd..c30ad9f16 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -80,7 +80,8 @@ export function renderOptionFields( shown.forEach((option) => { // 받은 값만 칸에 — 새 구조물은 부르는 쪽(패널)이 등록부 기본값을 `values` 로 실어 줌(PLAN 46-7). // 보기는 정의 전부로 세워 옛 값을 잃지 않고, 숨김 · 규칙 선택지는 `applyRules` 가 좁힘. - const input = optionControl(option, values[option.key]); + const name = (c: string): string => type.spec_names?.[`${option.key}=${c}`] ?? c; + const input = optionControl(option, values[option.key], name); const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`; const wrap = field(label, input, option.default_basis); wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) @@ -152,7 +153,11 @@ export function renderOptionFields( wrap.classList.toggle("is-rule-hidden", off); // 그룹 상자로 옮겨 가도 같은 표식 if (!off) optionInputs.push(entry); if (state && entry.input.tagName.toLowerCase() === "select") - syncChoices(entry.input as HTMLSelectElement, state.choices); + syncChoices( + entry.input as HTMLSelectElement, + state.choices, + (c) => type!.spec_names?.[`${entry.key}=${c}`] ?? c, + ); } } applyRules(null); @@ -161,14 +166,18 @@ export function renderOptionFields( } /** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */ -function syncChoices(element: HTMLSelectElement, choices: string[]): void { +function syncChoices( + element: HTMLSelectElement, + choices: string[], + label: (choice: string) => string, +): void { const now = [...element.children].slice(1).map((o) => (o as HTMLOptionElement).value); const keep = element.value && !choices.includes(element.value) ? [element.value] : []; const want = [...choices, ...keep]; if (now.length === want.length && now.every((v, i) => v === want[i])) return; const value = element.value; const blank = element.children[0]; - element.replaceChildren(blank, ...want.map((c) => new Option(c, c))); + element.replaceChildren(blank, ...want.map((c) => new Option(label(c), c))); element.value = value; } diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts index d16428ac1..1529bacc3 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def.ts @@ -32,6 +32,9 @@ import { checkBox, textBox } from "./M02_SO_Def_Ui"; import { buildGroupBlock } from "./M02_SO_Groups"; import { to } from "./M02_SO_Text"; +/** 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) — 모든 종류에서 숨김(52-12 · 정의 화면 = 이름만) */ +const OLD_EDITOR = false; + export interface DefScreenOptions { /** 시스템 관리자 — 아니면 읽기만 */ isAdmin: boolean; @@ -184,7 +187,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { } const edit = canEdit(); const extra = (): NonNullable => (d.종류[type.type_id] ??= {}); - // 명세가 있는 종류(PLAN 52) = 이름만 고침 — 옛 편집기(옵션 · 배치 · 그룹 · 규칙 · 기준 측점)는 숨김 + // 이름만 고침(PLAN 52-11 · 52-12) — 명세 종류 = 명세 나무 · 나머지 = 정의 그룹 나무 const spec = STRUCTURE_SPECS[type.type_id]; detail.replaceChildren( el("div", { @@ -200,7 +203,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { edit, attr: "data-sod-kind-off", }), - ...(spec + ...(!OLD_EDITOR ? [] : [ checkBox( @@ -221,7 +224,7 @@ function drawDef(first: StructDef, opt: DefScreenOptions): void { }), ], }), - ...(spec + ...(!OLD_EDITOR ? [buildSpecBlock({ def: d, type, spec, edit, changed })] : [ buildOptionsBlock({ def: d, type, edit, open, changed }), diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts index 63157ee50..1728f1662 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts @@ -3,14 +3,23 @@ * 구조물 명세가 있는 종류(PLAN 52 · `STRUCTURE_SPECS`)의 이름 고치기 칸(52-11) — 나무 꼴 * 컨테이너 제목 › 항목 이름 › 값 이름 · 이름만 고침 · 키 · 필수/선택 · 꼴은 읽기만 · * 아래 미리보기 = B05 · B06 이 그리는 같은 명세 폼(`createSpecForm`)에 고친 이름을 얹음. + * 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목 › 칸 이름 › 선택지 이름 · 미리보기 = B05 칸 부품 + * (`field` · `optionControl` · 그룹 상자)으로 같은 차례. * 구조물군 · 종류 이름은 왼쪽 목록 · 머리 칸이 그대로 고침. * ========================================================================== */ import { createTag, el } from "@ui/ui_template_elements"; import { createSpecForm } from "../../A00_Common/design/design_structure_form"; import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; +import { group } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields"; +import { field, optionControl } from "../../B05_Profile/B05_Profile_UI_Structures_Fields"; import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; -import { setSpecName, specNameRows, type SpecNameRow } from "./M02_SO_Def_Spec_Model"; +import { + groupNameRows, + setSpecName, + specNameRows, + type SpecNameRow, +} from "./M02_SO_Def_Spec_Model"; import { head, textBox } from "./M02_SO_Def_Ui"; import { to, type OptionsTextKey } from "./M02_SO_Text"; @@ -20,47 +29,90 @@ const TAG: Record, OptionsTextKey> = { select: "FormSelect", number: "FormNumber", auto: "FormAuto", + text: "FormText", + bool: "FormBool", }; +/** 정의 그룹 꼴 미리보기 — 그룹 밖 칸 · 그룹 상자(제목 = 그룹 이름) · 선택지 = 이름 표 글(값 그대로). */ +function groupPreview(def: StructDef, type: TypeDef): HTMLElement { + const names = def.종류[type.type_id]?.이름 ?? {}; + const options = type.options.filter((o) => o.enabled !== false); + const cell = (o: (typeof options)[number]): HTMLElement => + field( + o.unit ? `${o.label} (${o.unit})` : o.label, + optionControl( + { + input: o.input === "select" || o.input === "number" ? o.input : "text", + choices: o.choices ?? [], + default: null, + required: !!o.required, + }, + undefined, + (c) => names[`${o.key}=${c}`] ?? c, + ), + ); + const groups = def.그룹[type.type_id] ?? []; + const inGroup = new Set(groups.flatMap((g) => g.옵션)); + const grid = (cells: HTMLElement[]): HTMLElement => + el("div", { className: "b05-structure__grid", children: cells }); + return el("div", { + children: [ + grid(options.filter((o) => !inGroup.has(o.key)).map(cell)), + ...groups.map((g) => { + const box = group(g.이름); + box.body.append(grid(options.filter((o) => g.옵션.includes(o.key)).map(cell))); + return box.root; + }), + ], + }); +} + export function buildSpecBlock(opt: { def: StructDef; type: TypeDef; - spec: StructureSpec; + /** 없으면 정의 그룹 꼴(52-12) */ + spec: StructureSpec | undefined; edit: boolean; changed: () => void; }): HTMLElement { const { def, type, spec, edit, changed } = opt; - const rows = specNameRows(def, spec).map((row) => + const rows = (spec ? specNameRows(def, spec) : groupNameRows(def, type)).map((row) => el("div", { className: `m02-sod__specrow is-level-${row.level}`, attrs: { "data-sod-spec": row.nameKey }, children: [ - textBox(row.label, (v) => setSpecName(def, type.type_id, row, v), changed, { - edit, - attr: "data-sod-spec-name", - cls: "m02-sod__name", - }), + row.nameKey + ? textBox(row.label, (v) => setSpecName(def, type.type_id, row, v), changed, { + edit, + attr: "data-sod-spec-name", + cls: "m02-sod__name", + }) + : el("span", { text: to("SpecLoose") }), el("small", { className: "m02-opts__key", text: row.key }), ...(row.tag ? [createTag(to(TAG[row.tag]), "neutral")] : []), ], }), ); // 미리보기 — 값 없는 새 구조물 폼 · 잇기 줄 없음(조합은 프로젝트 몫) - const preview = createSpecForm(null); - const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름])); - preview.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups); - preview.root.inert = true; + let preview: HTMLElement; + if (spec) { + const form = createSpecForm(null); + const groups = Object.fromEntries((def.그룹[type.type_id] ?? []).map((g) => [g.id, g.이름])); + form.write(spec, {}, def.종류[type.type_id]?.이름 ?? {}, groups); + preview = form.root; + } else preview = groupPreview(def, type); + preview.inert = true; return el("div", { className: "m02-sod__block", attrs: { "data-sod-spec-block": "" }, children: [ ...head(to("SpecHead"), to("SpecNote")), - ...rows, + ...(rows.length ? rows : [el("p", { className: "m02-side__empty", text: to("SpecNone") })]), ...head(to("SpecPreview")), el("div", { className: "m02-sod__specpreview", attrs: { "data-sod-spec-preview": "" }, - children: [preview.root], + children: [preview], }), ], }); diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts index bd6bc693b..52634ecbc 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts @@ -6,25 +6,29 @@ * · 이름 자리 = 정의 `종류.{종류}.이름`(키 `{컨테이너}` · `{컨테이너}.{항목}` · `{컨테이너}.{항목}={값}`) * · 필수 컨테이너 제목 = 이름 표에 없으면 정의 그룹(`그룹.{종류}` 의 `id = link`) 이름 — B05 그리기 * (`containerTitle`)와 같은 차례로 읽고 그 자리에 씀 + * · 명세 없는 종류(정의 그룹 꼴 · 52-12) = 그룹 제목(`그룹.이름`) › 칸 이름(옵션 `label`) › + * 선택지 이름(이름 표 키 `{옵션}={값}` · 값은 그대로) — 그룹 밖 칸은 맨 앞 머리 줄 아래 * import 는 꼴만 — 시험이 이 파일만 옮겨 돌림. * ========================================================================== */ import type { StructureSpec } from "../../A00_Common/design/design_structure_spec"; -import type { StructDef } from "./M02_SO_Def_Model"; +import type { StructDef, TypeDef } from "./M02_SO_Def_Model"; export interface SpecNameRow { /** 0 = 컨테이너 · 1 = 항목 · 2 = 값 */ level: 0 | 1 | 2; - /** 이름 표 키 */ + /** 이름 표 키 — 빈 글 = 고칠 이름 없는 머리 줄(그룹 밖 칸) */ nameKey: string; /** 읽기만 표시 — 컨테이너 id · 항목 id · 값 */ key: string; /** 지금 이름 */ label: string; /** 읽기만 표시 — 필수/선택 · 꼴 */ - tag: "required" | "optional" | "select" | "number" | "auto" | null; + tag: "required" | "optional" | "select" | "number" | "auto" | "text" | "bool" | null; /** 필수 컨테이너 제목이 정의 그룹 이름에서 옴 — 고치면 그 그룹 이름에 씀 */ group: string | null; + /** 정의 옵션 이름(`label`)에서 옴 — 고치면 그 옵션 이름에 씀 */ + option: string | null; } /** 명세 차례대로 이름 줄 — 같은 이름 키(갈래마다 겹친 값)는 한 줄. */ @@ -48,6 +52,7 @@ export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] label: names[c.id] ?? group?.이름 ?? c.title, tag: c.required ? "required" : "optional", group: group ? group.id : null, + option: null, }); for (const item of c.items) { const itemKey = `${c.id}.${item.id}`; @@ -58,6 +63,7 @@ export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] label: names[itemKey] ?? item.label, tag: item.kind, group: null, + option: null, }); const choices = [ ...(item.choices ?? []), @@ -72,6 +78,7 @@ export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] label: names[choiceKey] ?? ch.label, tag: null, group: null, + option: null, }); } } @@ -79,8 +86,67 @@ export function specNameRows(def: StructDef, spec: StructureSpec): SpecNameRow[] return rows; } -/** 이름 하나를 사본에 씀 — 그룹에서 온 제목은 그 그룹 이름 · 나머지는 이름 표. */ +/** 정의 그룹 꼴 종류(명세 없음) 이름 줄 — 그룹 밖 칸 · 그룹마다 제목 › 칸 › 선택지(B05 그리기 차례). */ +export function groupNameRows(def: StructDef, type: TypeDef): SpecNameRow[] { + const names = def.종류[type.type_id]?.이름 ?? {}; + const options = type.options.filter((o) => o.enabled !== false); + const groups = def.그룹[type.type_id] ?? []; + const inGroup = new Set(groups.flatMap((g) => g.옵션)); + const rows: SpecNameRow[] = []; + const optionRows = (keys: readonly string[]): void => { + for (const o of options.filter((x) => keys.includes(x.key))) { + rows.push({ + level: 1, + nameKey: o.key, + key: o.key, + label: o.label, + tag: o.input, + group: null, + option: o.key, + }); + for (const ch of o.input === "select" ? (o.choices ?? []) : []) { + const nameKey = `${o.key}=${ch}`; + rows.push({ + level: 2, + nameKey, + key: ch, + label: names[nameKey] ?? ch, + tag: null, + group: null, + option: null, + }); + } + } + }; + const loose = options.filter((o) => !inGroup.has(o.key)).map((o) => o.key); + if (loose.length) { + rows.push({ level: 0, nameKey: "", key: "", label: "", tag: null, group: null, option: null }); + optionRows(loose); + } + for (const g of groups) { + rows.push({ + level: 0, + nameKey: `#${g.id}`, + key: g.id, + label: g.이름, + tag: null, + group: g.id, + option: null, + }); + optionRows(g.옵션); + } + return rows; +} + +/** 이름 하나를 사본에 씀 — 그룹에서 온 제목은 그 그룹 이름 · 옵션 칸은 옵션 이름 · 나머지는 이름 표. */ export function setSpecName(def: StructDef, typeId: string, row: SpecNameRow, value: string): void { + const option = row.option + ? def.types.find((t) => t.type_id === typeId)?.options.find((o) => o.key === row.option) + : undefined; + if (option) { + option.label = value; + return; + } const group = row.group ? def.그룹[typeId]?.find((g) => g.id === row.group) : undefined; if (group) { group.이름 = value; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts index 3235e7762..a2c9f8c46 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Text.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Text.ts @@ -48,9 +48,11 @@ const TEXT = { FormAuto: ["자동", "Auto"], SpecHead: ["구조물 명세 이름", "Structure spec names"], SpecNote: [ - "컨테이너 제목 › 항목 이름 › 값 이름만 고침 — 키(작은 글) · 필수/선택 · 꼴 · 구조는 구조물 명세가 정함 · [저장]하면 B05 · B06 이 이 이름으로 그림", - "Rename containers › items › values only — keys, required/optional, form and structure come from the spec", + "제목 › 칸 이름 › 선택지 이름만 고침 — 키(작은 글) · 필수/선택 · 꼴 · 칸 더하기 · 빼기는 읽기만 · [저장]하면 B05 · B06 이 이 이름으로 그림", + "Rename titles › fields › choices only — keys, required/optional, form and adding/removing fields are read-only", ], + SpecLoose: ["그룹 밖 칸", "Fields outside groups"], + SpecNone: ["고칠 이름 없음 — 칸이 없는 종류", "No names to edit — this kind has no fields"], SpecRequired: ["필수", "Required"], SpecOptional: ["선택", "Optional"], SpecPreview: ["미리보기 — B05 · B06 폼", "Preview — B05 · B06 form"], diff --git a/resources/tester/helper_m02_so_def_spec.cjs b/resources/tester/helper_m02_so_def_spec.cjs index ede10f95b..6634b447e 100644 --- a/resources/tester/helper_m02_so_def_spec.cjs +++ b/resources/tester/helper_m02_so_def_spec.cjs @@ -6,12 +6,20 @@ const path = require("path"); const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); const ROOT = path.join(__dirname, "..", ".."); +// 이웃 TS(`./…`)는 같은 길로 읽음 — 명세 파일이 서로 import 함 +const cache = {}; const load = (...parts) => { - const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, ...parts), "utf8"), { + const file = path.join(ROOT, ...parts); + if (cache[file]) return cache[file].exports; + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, }).outputText; - const box = { exports: {} }; - new Function("exports", "module", "require", js)(box.exports, box, require); + const box = (cache[file] = { exports: {} }); + const near = (id) => + id.startsWith(".") + ? load(path.relative(ROOT, path.resolve(path.dirname(file), `${id}.ts`))) + : require(id); + new Function("exports", "module", "require", js)(box.exports, box, near); return box.exports; }; const m = load("M02_MasterTemplete", "StructOptions", "M02_SO_Def_Spec_Model.ts"); @@ -61,4 +69,27 @@ m.setSpecName( ); out.bareGroup = bare.그룹.pipe.find((g) => g.id === "gnbf9m9").이름; +// 정의 그룹 꼴(52-12) — 맹암거 = 그룹 제목 › 칸 · 옆도랑 = 그룹 밖 칸 › 선택지 +const g = real(); +const ud = g.types.find((t) => t.type_id === "cross_underdrain"); +const udRows = m.groupNameRows(g, ud); +out.udRows = udRows.map((r) => [r.level, r.nameKey, r.label]); +m.setSpecName(g, ud.type_id, udRows[0], "맹암거 본체"); +m.setSpecName(g, ud.type_id, udRows[1], "암거 높이 칸"); +out.udGroup = g.그룹.cross_underdrain[0].이름; +out.udLabel = ud.options[0].label; +const side = g.types.find((t) => t.type_id === "ditch_side"); +const sideRows = m.groupNameRows(g, side); +out.sideHead = sideRows[0]; +const lshape = sideRows.find((r) => r.nameKey === "form=L형"); +out.sideChoice = lshape && [lshape.level, lshape.label]; +m.setSpecName(g, side.type_id, lshape, "L 모양"); +out.sideName = g.종류.ditch_side.이름["form=L형"]; +out.sideChoices = side.options.find((o) => o.key === "form").choices; +out.sideReread = m.groupNameRows(g, side).find((r) => r.nameKey === "form=L형").label; +out.bareKind = m.groupNameRows( + g, + g.types.find((t) => t.type_id === "open_ditch"), +).length; + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_m02_so_def_spec.py b/resources/tester/test_m02_so_def_spec.py index 3677816ff..e575dad1a 100644 --- a/resources/tester/test_m02_so_def_spec.py +++ b/resources/tester/test_m02_so_def_spec.py @@ -8,6 +8,8 @@ pytest 는 그 결과(JSON)를 판정. - 필수 컨테이너 제목은 정의 그룹 이름에 씀 · 항목 · 값 · 선택 컨테이너는 이름 표에 씀. - 이름만 바뀜 — types(옵션 · 키) 그대로 · 이름 표에 없는 키를 새로 만들지 않음. - 이름 표가 빈 종류는 명세 글로 읽음. + - 정의 그룹 꼴(52-12): 그룹 제목 › 칸 › 선택지 · 제목 = 그룹 이름 · 칸 = 옵션 이름 · + 선택지 = 이름 표(값 그대로) · 칸 없는 종류는 줄 없음. """ from __future__ import annotations @@ -56,3 +58,17 @@ def test_구조는_그대로(r) -> None: def test_이름_표_없는_종류(r) -> None: assert r["bareLabel"] == "흙막이" assert r["bareGroup"] == "유입" + + +def test_정의_그룹_꼴(r) -> None: + assert r["udRows"] == [ + [0, "#gcrossud", "맹암거"], + [1, "culvert_height_m", "암거 높이"], + [1, "culvert_slope_pct", "암거 경사"], + ] + assert r["udGroup"] == "맹암거 본체" and r["udLabel"] == "암거 높이 칸" + assert r["sideHead"]["nameKey"] == "" and r["sideHead"]["level"] == 0 + assert r["sideChoice"] == [2, "L형"] + assert r["sideName"] == "L 모양" and r["sideReread"] == "L 모양" + assert "L형" in r["sideChoices"] + assert r["bareKind"] == 0 diff --git a/resources/tester/ui_checks/52-11_정의_이름고치기.js b/resources/tester/ui_checks/52-11_정의_이름고치기.js index 6d4679afe..8cffe5b2b 100644 --- a/resources/tester/ui_checks/52-11_정의_이름고치기.js +++ b/resources/tester/ui_checks/52-11_정의_이름고치기.js @@ -1,4 +1,5 @@ /* 52-11 — M02 「구조물 옵션 정의」 = 구조물 명세 이름 고치기(명세 있는 종류) · B05 가 그 이름으로 그림 + * 52-12 — 정의 그룹 꼴 종류(맹암거 · 개거 · 바닥막이 …)도 같은 이름 나무 · 옛 편집기는 모든 종류에서 숨김 * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5211 = "단계";` 를 붙여 * M02 마스터 층(#/m02-master-template?root=master) · 시스템 관리자: * orca eval --expression "window.__m5211='m02'; $(cat 이 파일)" @@ -7,11 +8,14 @@ * 쓰는 것: 없음 — M02 는 화면 사본만 고치고 [저장] 안 누름 · 되돌린 뒤 판 그대로를 GET 으로 봄 · * B05 는 받아 둔 종류 객체(메모리)만 고침 · 끝나면 orca reload 로 사본을 버림 * 기대 수치(m02): - * - spec = 배관 나무 줄(컨테이너 5) · old = 옛 편집기 0(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) · other = 명세 없는 종류는 옛 편집기 + * - spec = 배관 나무 줄(컨테이너 5) · old = 옛 편집기 0(옵션 · 배치 · 그룹 · 규칙 · 기준 측점) · other = 명세 없는 종류도 이름 나무 + * - 맹암거 = 그룹 제목 › 칸 2 · 고치면 미리보기 그룹 상자 제목 · 칸 이름 따라감 + * - 바닥막이 = 선택지 이름 고치면 미리보기 보기 글 따라감(값 그대로) · 개거 = 고칠 이름 없음 · 종류 이름만(왼쪽 목록 따라감) * - 이름 칸만 고침: inputs = 줄 수 · 키 · 꼴은 글(입력 칸 아님) * - 컨테이너 제목 · 항목 이름 · 값 이름 고침 → 미리보기 legend · 칸 이름 · 선택지 글 따라감 · [저장] 켜짐 * - 빈 이름 → issues 「명세 이름」 · [저장] 꺼짐 · 되돌리면 이름 그대로 · 판 그대로 * 기대 수치(b05): 사본 이름(컨테이너 · 항목 · 값)이 배관 폼에 그대로 → named.* = true + * group.* = 맹암거 그룹 제목 · 칸 이름 · 바닥막이 선택지 글이 새 구조물 폼에 그대로(값 그대로) */ (async () => { const MODE = window.__m5211 || "m02"; @@ -44,10 +48,74 @@ row.click(); if (!(await until(() => $$("[data-sod-kind]").length))) return JSON.stringify({ error: "종류 목록 없음" }); - // 명세 없는 종류 — 옛 편집기 그대로 + // 명세 없는 종류 — 옛 편집기 숨김 · 이름 나무(52-12) $('[data-sod-kind="retaining_wall"]').click(); await W(200); - out.other = { base: !!$("[data-sod-base]"), spec: !!$("[data-sod-spec-block]") }; + out.other = { + base: !!$("[data-sod-base]"), + spec: !!$("[data-sod-spec-block]"), + blocks: $("[data-sod-detail]").children.length, + }; + const nameBox = (k) => $(`[data-sod-spec="${k}"] [data-sod-spec-name]`); + const groupPreview = () => $("[data-sod-spec-preview]"); + // 맹암거 — 그룹 제목 › 칸 + $('[data-sod-kind="cross_underdrain"]').click(); + await W(200); + { + const keys = $$("[data-sod-spec]").map((r) => r.dataset.sodSpec); + const g0 = nameBox("#gcrossud").value; + const o0 = nameBox("culvert_height_m").value; + put(nameBox("#gcrossud"), "맹암거X"); + await W(150); + put(nameBox("culvert_height_m"), "암거 높이X"); + await W(150); + const pv = groupPreview(); + out.ud = { + keys, + legend: [...pv.querySelectorAll("legend")].some((l) => l.textContent === "맹암거X"), + label: pv.textContent.includes("암거 높이X (m)"), + old: !!$("[data-sod-option]") || !!$("[data-sod-base]"), + }; + put(nameBox("#gcrossud"), g0); + await W(150); + put(nameBox("culvert_height_m"), o0); + await W(150); + out.ud.restored = + nameBox("#gcrossud").value === g0 && nameBox("culvert_height_m").value === o0; + } + // 바닥막이 — 선택지 이름(값 그대로) + $('[data-sod-kind="bed_sill"]').click(); + await W(200); + { + const k = "form=돌붙임(메)"; + const c0 = nameBox(k).value; + put(nameBox(k), "돌붙임 메X"); + await W(150); + const opt = [...groupPreview().querySelectorAll("option")].find( + (o) => o.textContent === "돌붙임 메X", + ); + out.bed = { choice: !!opt, value: opt?.value }; + put(nameBox(k), c0); + await W(150); + out.bed.restored = nameBox(k).value === c0; + } + // 개거 — 칸 없음 · 종류 이름만 + $('[data-sod-kind="open_ditch"]').click(); + await W(200); + { + const kind = $("[data-sod-kind-name]"); + const n0 = kind.value; + out.ditch = { + rows: $$("[data-sod-spec]").length, + none: !!$("[data-sod-spec-block] p.m02-side__empty"), + }; + put(kind, "개거X"); + await W(200); + out.ditch.list = $('[data-sod-kind="open_ditch"]').textContent.includes("개거X"); + put($("[data-sod-kind-name]"), n0); + await W(200); + out.ditch.restored = $('[data-sod-kind="open_ditch"]').textContent.includes(n0); + } // 배관 — 명세 이름 나무 $('[data-sod-kind="pipe"]').click(); await W(200); @@ -102,8 +170,21 @@ out.restored = Object.entries(orig).every(([k, v]) => box(k).value === v); out.판After = (await getDef()).판; out.ok = - out.other.base && - !out.other.spec && + !out.other.base && + out.other.spec && + out.other.blocks === 2 && + out.ud.keys.join() === "#gcrossud,culvert_height_m,culvert_slope_pct" && + out.ud.legend && + out.ud.label && + !out.ud.old && + out.ud.restored && + out.bed.choice && + out.bed.value === "돌붙임(메)" && + out.bed.restored && + out.ditch.rows === 0 && + out.ditch.none && + out.ditch.list && + out.ditch.restored && out.spec.containers === 5 && out.spec.inputs === out.spec.rows && out.spec.nameInputs === out.spec.rows && @@ -149,7 +230,41 @@ ), }; (li.querySelector("button") ?? li).click(); // 재선택 = 해제 - out.ok = Object.values(out.named).every(Boolean); + await W(500); + // 정의 그룹 꼴(52-12) — 받아 둔 종류 객체만 고치고 새 구조물 폼(군 · 종류 고르기 · 놓지 않음)으로 봄 + const types = await api.fetchStructureTypes(true); + const ud = types.find((t) => t.type_id === "cross_underdrain"); + ud.groups[0].name = "맹암거X"; + ud.options[0].label = "암거 높이X"; + const bed = types.find((t) => t.type_id === "bed_sill"); + bed.spec_names = { ...(bed.spec_names ?? {}), "form=돌붙임(메)": "돌붙임 메X" }; + const count = $$(".b05-route__irregular-list li").length; + const [famSel, kindSel] = $$("select", sec); + const choose = async (fam, kind) => { + famSel.value = fam; + famSel.dispatchEvent(new Event("change", { bubbles: true })); + await W(300); + kindSel.value = kind; + kindSel.dispatchEvent(new Event("change", { bubbles: true })); + await W(500); + }; + await choose("A", "cross_underdrain"); + out.group = { + legend: $$("legend", sec).some( + (l) => l.offsetParent !== null && l.textContent.includes("맹암거X"), + ), + label: $$(".b05-route__field > span", sec).some( + (n) => n.offsetParent !== null && n.textContent === "암거 높이X (m)", + ), + }; + await choose("D", "bed_sill"); + const opt = $$("option", sec).find((o) => o.textContent === "돌붙임 메X"); + out.group.choice = !!opt && opt.value === "돌붙임(메)"; + famSel.value = ""; + famSel.dispatchEvent(new Event("change", { bubbles: true })); + await W(300); + out.group.noWrite = $$(".b05-route__irregular-list li").length === count; + out.ok = Object.values(out.named).every(Boolean) && Object.values(out.group).every(Boolean); } return JSON.stringify(out); })(); From 220a7ddc59bc2b2af726b6b33c5844c58f994256 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 00:03:22 +0900 Subject: [PATCH 16/37] =?UTF-8?q?fix(B06):=20=ED=9A=A1=EB=8B=A8=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=20=EC=A4=84=20=EC=9E=90=EB=A6=AC=20=E2=80=94?= =?UTF-8?q?=20=EC=88=A8=EC=9D=80=20=EB=AA=85=EC=84=B8=20=ED=8F=BC=20?= =?UTF-8?q?=EC=B9=B8=EC=9D=80=20=EB=BA=8C=20(PLAN=2052-2b)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - slotUsable = fieldset 안 · 숨은 조상 없음 — 앞 시설 명세 폼이 숨은 채 남아도 그 칸으로 안 옮김 - 화면 시나리오 52-2b(배관 → BOX → 세월교 → 배관 · 유입 · 유출) — 줄이 보이는 명세 폼 inlet · outlet 칸에 섬 · 옛 판정이 숨은 칸을 쓸 수 있음으로 본 건수 14 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- B06_Section/B06_Section_UI_Adjust_Dock.ts | 6 +- .../tester/ui_checks/52-2b_조정줄_자리.js | 83 +++++++++++++++++++ 2 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 resources/tester/ui_checks/52-2b_조정줄_자리.js diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 9df78bb84..7395f61ff 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -57,11 +57,9 @@ const SIDE_ROWS = [ ".b06-structure-panel__struct--wall-height", ].join(", "); -/** 이식 자리가 지금 화면에 서 있는지 — 숨겨진 그룹(다른 시설)이면 옮기지 않는다. */ +/** 이식 자리가 지금 화면에 서 있는지 — 그 위 어느 줄이든 숨었으면(다른 시설 명세 폼 등) 옮기지 않는다. */ function slotUsable(target: HTMLElement | undefined): target is HTMLElement { - if (!target) return false; - const group = target.closest("fieldset"); - return !!group && !group.hidden; + return !!target?.closest("fieldset") && !target.closest("[hidden]"); } /** 배관이면 유입구·유출구 칸, 독립 기슭막이면 좌·우 칸, 세월교면 날개벽 칸 — diff --git a/resources/tester/ui_checks/52-2b_조정줄_자리.js b/resources/tester/ui_checks/52-2b_조정줄_자리.js new file mode 100644 index 000000000..7abeee314 --- /dev/null +++ b/resources/tester/ui_checks/52-2b_조정줄_자리.js @@ -0,0 +1,83 @@ +/* 52-2b — B06 횡단 조정 줄(단 수 · 옵션 · 읽기)이 서는 자리 = 지금 보이는 명세 폼의 유입 · 유출 칸 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub3_검증) · 목록에 「배관 1」 「BOX암거」 「세월교 1」 + * 부르는 법: orca eval --expression "$(cat 이 파일)" — 쓰는 것 없음(가짜 조정 사본을 세웠다 내림) + * 차례: 배관 1 → BOX암거 → 세월교 1 → 배관 1 (앞 시설 명세 폼이 숨은 채 남는 경우까지) + * 기대 수치: 모든 단계 · 양쪽 — 줄이 숨은 조상 없이 보이는 자리 · 그 시설 명세 폼의 inlet/outlet 칸 · + * 숨은 명세 칸을 「쓸 수 있음」으로 보던 옛 판정 건수(oldWouldPickHidden)는 참고값 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const dock = await import(modUrl("/B06_Section/B06_Section_UI_Adjust_Dock.ts")); + const visibleSpec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => !n.closest("[hidden]")); + const firstContainer = () => + visibleSpec()?.querySelector("[data-spec-container]")?.dataset.specContainer; + const pick = async (name, head) => { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find( + (l) => l.offsetParent !== null && name.test(l.textContent.trim()), + ); + (li.querySelector("button") ?? li).click(); + await w(800); + if (firstContainer() !== head) (li.querySelector("button") ?? li).click(); + return until(() => firstContainer() === head, 8000); + }; + // 옛 판정 — 바로 위 fieldset 만 봄 + const oldUsable = (t) => !!t.closest("fieldset") && !t.closest("fieldset").hidden; + const fakePanel = (side) => { + const panel = document.createElement("div"); + panel.dataset.side = side; + panel.dataset.panelTitle = "52-2b 시험"; + const row = document.createElement("div"); + row.className = "b06-structure-panel__struct--tier"; + row.dataset.m522b = side; + panel.append(row); + return { panel, row }; + }; + + const steps = [ + [/배관 1$/, "pipe"], + [/BOX암거$/, "body"], + [/세월교 1$/, "ford"], + [/배관 1$/, "pipe"], + ]; + const out = { steps: [], oldWouldPickHidden: 0 }; + for (const [name, head] of steps) { + const step = { name: String(name), shown: await pick(name, head) }; + for (const side of ["inlet", "outlet"]) { + // 숨은 명세 폼의 같은 칸 — 옛 판정이 「쓸 수 있음」으로 봤는지 + for (const s of document.querySelectorAll( + `[data-spec-container="${side}"] .b05-structure__adjust-slot`, + )) + if (s.closest("[hidden]") && oldUsable(s)) out.oldWouldPickHidden++; + const { panel, row } = fakePanel(side); + dock.adoptAdjustPanel(panel); + step[side] = { + visible: !row.closest("[hidden]"), + container: row.closest("[data-spec-container]")?.dataset.specContainer ?? null, + inShownForm: !!visibleSpec()?.contains(row), + }; + dock.releaseAdjustPanel(panel); + } + out.steps.push(step); + } + out.pass = out.steps.every( + (s) => + s.shown && + ["inlet", "outlet"].every((k) => s[k].visible && s[k].inShownForm && s[k].container === k), + ); + return JSON.stringify(out); +})(); From b02cd0bb992b56c3bfb6dd67ff31d1116e362744 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 00:05:30 +0900 Subject: [PATCH 17/37] =?UTF-8?q?test(m02):=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20=EC=8B=9C=ED=97=98=EC=9D=98=20=ED=8C=90?= =?UTF-8?q?=20=EC=88=AB=EC=9E=90=20=EA=B3=A0=EC=A0=95=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=E2=80=94=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=A9=B4=20=ED=8C=90?= =?UTF-8?q?=20+=201=20=C2=B7=20=EC=9D=B4=EB=A0=A5=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EA=B9=80=20=EA=BC=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판 1 = 옛 레지스트리 검사는 이력 판_1 로 · 지금 정의는 검사 통과 · 종류·옵션 수 일치 - 쓰기 시험은 사본 정의의 지금 판 기준 · 진짜 구조물정의.json 안 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../tester/test_m02_structure_definition.py | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/resources/tester/test_m02_structure_definition.py b/resources/tester/test_m02_structure_definition.py index d4df44197..675dd2af3 100644 --- a/resources/tester/test_m02_structure_definition.py +++ b/resources/tester/test_m02_structure_definition.py @@ -3,7 +3,7 @@ 못박는 것 - 판 1 = 옛 레지스트리 그대로 옮김(37종 · 옵션 235 · 키 그대로) · 검사 통과. - 읽기 = 로그인 누구나 · 쓰기 = 시스템 관리자만(403). - - 저장마다 판 +1 · 앞 판은 `구조물정의_이력/판_N.json` · 낡은 판 409 · 틀린 정의 422(안 씀). + - 저장마다 판 +1(지금 판에서) · 앞 판은 `구조물정의_이력/판_N.json` · 낡은 판 409 · 틀린 정의 422(안 씀). - 검사 = 키 겹침 · 꼴 · 규칙 대상 있음 · 그룹 형상. - 저장한 정의를 `/structure-types` 가 바로 읽음 · 프로젝트 저장에 정의 판 · 없는 키 값 보존. """ @@ -49,12 +49,16 @@ def _client(session: dict) -> TestClient: def test_판1_은_옛_레지스트리_그대로_검사_통과() -> None: - doc = definition.read() + doc = definition.read_version(1) assert doc["판"] == 1 and len(doc["types"]) == 37 assert sum(len(t["options"]) for t in doc["types"]) == 235 - assert len(load_structure_types()) == 37 - assert sum(len(t.options) for t in load_structure_types()) == 235 assert definition.check(doc) == [] + now = definition.read() + assert now["판"] >= 1 and definition.check(now) == [] + assert len(load_structure_types()) == len(now["types"]) + assert sum(len(t.options) for t in load_structure_types()) == sum( + len(t["options"]) for t in now["types"] + ) assert [g["이름"] for g in doc["그룹"]["pipe"]] == ["관", "관 보호공(유입)", "관 보호공(유출)"] assert {t["group"] for t in doc["types"]} <= set(doc["구조물군"]) @@ -62,26 +66,28 @@ def test_판1_은_옛_레지스트리_그대로_검사_통과() -> None: def test_읽기는_누구나_쓰기는_시스템_관리자만(copy) -> None: user = _client(USER) got = user.get(URL) - assert got.status_code == 200 and got.json()["판"] == 1 - assert user.put(URL, json={"판": 1, "정의": got.json()}).status_code == 403 - assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 안 씀 + v = json.loads(copy.read_text("utf-8"))["판"] + assert got.status_code == 200 and got.json()["판"] == v + assert user.put(URL, json={"판": v, "정의": got.json()}).status_code == 403 + assert json.loads(copy.read_text("utf-8"))["판"] == v # 안 씀 def test_저장마다_판_더하기_1_앞_판은_이력_structure_types_가_바로_읽음(copy) -> None: admin = _client(ADMIN) doc = admin.get(URL).json() + v = doc["판"] tid = doc["types"][0]["type_id"] doc["types"][0]["name"] = "이름 바꿈" - res = admin.put(URL, json={"판": 1, "정의": doc}) + res = admin.put(URL, json={"판": v, "정의": doc}) assert res.status_code == 200, res.text - assert res.json()["판"] == 2 - old = json.loads((copy.parent / "구조물정의_이력" / "판_1.json").read_text("utf-8")) - assert old["판"] == 1 and old["types"][0]["name"] != "이름 바꿈" + assert res.json()["판"] == v + 1 + old = json.loads((copy.parent / "구조물정의_이력" / f"판_{v}.json").read_text("utf-8")) + assert old["판"] == v and old["types"][0]["name"] != "이름 바꿈" seen = {t["type_id"]: t for t in admin.get(TYPES).json()["types"]} - assert seen[tid]["name"] == "이름 바꿈" and len(seen) == 37 - assert admin.put(URL, json={"판": 1, "정의": doc}).status_code == 409 # 낡은 판 - assert admin.put(URL, json={"판": 2, "정의": res.json()}).json()["판"] == 3 - assert (copy.parent / "구조물정의_이력" / "판_2.json").is_file() + assert seen[tid]["name"] == "이름 바꿈" and len(seen) == len(doc["types"]) + assert admin.put(URL, json={"판": v, "정의": doc}).status_code == 409 # 낡은 판 + assert admin.put(URL, json={"판": v + 1, "정의": res.json()}).json()["판"] == v + 2 + assert (copy.parent / "구조물정의_이력" / f"판_{v + 1}.json").is_file() def _rule(doc: dict) -> tuple[str, dict, dict]: @@ -96,6 +102,7 @@ def _rule(doc: dict) -> tuple[str, dict, dict]: def test_검사_키_겹침_꼴_규칙_대상_형상(copy) -> None: admin = _client(ADMIN) base = admin.get(URL).json() + v = base["판"] tid, up, down = _rule(base) good = { "종류": tid, @@ -133,13 +140,13 @@ def test_검사_키_겹침_꼴_규칙_대상_형상(copy) -> None: bad(모르는칸=1), ] for i, doc in enumerate(cases): - res = admin.put(URL, json={"판": 1, "정의": doc}) + res = admin.put(URL, json={"판": v, "정의": doc}) assert res.status_code == 422, (i, res.text) - assert json.loads(copy.read_text("utf-8"))["판"] == 1 # 거절되면 안 씀 + assert json.loads(copy.read_text("utf-8"))["판"] == v # 거절되면 안 씀 assert not (copy.parent / "구조물정의_이력").exists() shaped = bad(종류={tid: {"기준측점": False}}) shaped["그룹"]["pipe"][0]["형상"] = {"길이": {"받기": True, "뜻": "관 길이"}} - assert admin.put(URL, json={"판": 1, "정의": shaped}).status_code == 200 + assert admin.put(URL, json={"판": v, "정의": shaped}).status_code == 200 def test_프로젝트_저장에_정의_판_없는_키_값은_남김(copy, tmp_path) -> None: @@ -154,5 +161,5 @@ def test_프로젝트_저장에_정의_판_없는_키_값은_남김(copy, tmp_pa ) assert save_structures(root, [item], base_revision=0) == 1 raw = json.loads(Path(root, "B05_Profile", "route", "structures.json").read_text("utf-8")) - assert raw["definition_version"] == 1 + assert raw["definition_version"] == definition.read()["판"] assert load_structures(root)[1][0].options["지난정의키"] == "옛값" From 3e5c64839ac730d3330c12b2da357b553fd8005b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 00:08:44 +0900 Subject: [PATCH 18/37] =?UTF-8?q?feat(design):=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=ED=8B=80=20=EA=B8=80=20=EC=86=90=20=EC=9E=85=EB=A0=A5=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=C2=B7=20=EA=B8=B0=ED=83=80=20=EC=84=A0?= =?UTF-8?q?=ED=83=9D=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EB=82=B4=EC=9A=A9=20=C2=B7=20=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=3D=20=EC=A7=91=EA=B3=84=20=EA=B1=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84(52-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세 항목 꼴 「글」(kind text · 길이 상한 TEXT_MAX 100 · 비우기 · 이름 표 · M02 이름 나무 꼴 「글」) - 다 빈 선택 인스턴스도 첫 칸을 빈 글로 남겨 자리 지킴 — 잇기 · 집계 줄 · 다시 열기 - 기타 = 선택 컨테이너 「상세 내용」(비고 글 · etc_detail_{n}_note · 잇기 getcdetail{n}) · 종류 단위 잇기 줄 가림 규칙 그대로 - 집계 모음: 기타 건 이름(그룹) = 상세 종류 이름 · 비면 「기타」 · 컨테이너 건 = 이름 + n · 서버 컨테이너 표에 기타 짝 - 시험: test_52_14_etc_text · 헬퍼 etc · ui_checks 52-10 에 기타 컨테이너 단계 · 52-1 스크립트는 보이는 명세 폼을 잡음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- A00_Common/design/design_structure_form.ts | 10 +- A00_Common/design/design_structure_pipe.ts | 3 +- A00_Common/design/design_structure_road.ts | 17 +++ A00_Common/design/design_structure_spec.ts | 17 ++- M02_MasterTemplete/M02_Table_Fill.py | 6 +- M02_MasterTemplete/M02_Table_Fill_Spec.py | 16 ++- .../StructOptions/M02_SO_Def_Spec.ts | 1 + .../StructOptions/M02_SO_Def_Spec_Model.ts | 2 +- resources/tester/helper_52_structure_spec.cjs | 27 +++++ resources/tester/test_52_14_etc_text.py | 106 ++++++++++++++++++ .../tester/ui_checks/52-10_노면공_기타.js | 44 +++++++- .../tester/ui_checks/52-1_배관_컨테이너.js | 4 +- 12 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 resources/tester/test_52_14_etc_text.py diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index e6df8ec91..31af0f4c0 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -3,7 +3,7 @@ * 구조물 명세(`design_structure_spec`) 그리기 — B05 · B06 「구조물 배치」 칸이 같은 노드를 씀. * * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] - * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 자동(읽기만) + * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) * · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) — * 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김) * · 값은 폼이 쥠 — 저장은 패널이 `read()` 로 읽어 정본 자리(옵션)에 씀 @@ -19,6 +19,7 @@ import { linkOf, readState, solveSplit, + TEXT_MAX, writeOptions, type SpecContainer, type SpecItem, @@ -111,6 +112,13 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { select.value = now; return select; } + if (item.kind === "text") { + const input = document.createElement("input"); + input.type = "text"; + input.maxLength = TEXT_MAX; + input.value = values[item.id] ?? ""; + return input; + } const input = numberInput(String(item.step ?? 0.1), "0", hooks.hint?.(c, item, state) ?? ""); if (item.kind === "auto") { input.type = "text"; diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index f00fef9ad..f19c64d79 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -13,7 +13,7 @@ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; import { FORD_SPEC } from "./design_structure_ford"; -import { PAVEMENT_SPEC } from "./design_structure_road"; +import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 const BASIN = "집수정"; @@ -190,4 +190,5 @@ export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC, ford_bridge: FORD_SPEC, pavement_concrete: PAVEMENT_SPEC, + etc: ETC_SPEC, }; diff --git a/A00_Common/design/design_structure_road.ts b/A00_Common/design/design_structure_road.ts index 4d1f7af13..3568dc85b 100644 --- a/A00_Common/design/design_structure_road.ts +++ b/A00_Common/design/design_structure_road.ts @@ -5,6 +5,8 @@ * 기준 측점(패널 위치 칸) + 필수 컨테이너 「상세 내용」 한 벌 = 포장사양 고르기 · 높이(m) 손 입력. * 구간 길이 · 전 · 후는 정의 옵션 칸 그대로(구간 배치) · 표에 없는 옛 상세 칸은 화면에서 빼고 값은 남김. * 저장 = structures.json `options` — 새 키 `pave_spec` · `height_m`. + * 기타(52-14) = 기준 측점 + 상세 종류 이름(정의 옵션 칸 `name`) + 선택 컨테이너 「상세 내용」(비고 글) · + * 저장 키 `etc_detail_{n}_note` · 잇기 `getcdetail{n}`. * import 없음 — 시험이 이 파일만 옮겨 돌림. * ========================================================================== */ @@ -43,3 +45,18 @@ export const PAVEMENT_SPEC: StructureSpec = { "wire_mesh", ], }; + +export const ETC_SPEC: StructureSpec = { + typeId: "etc", + containers: [ + { + id: "detail", + title: "상세 내용", + required: false, + prefix: "etc_detail", + link: "getcdetail", + items: [{ id: "note", label: "비고", kind: "text" }], + }, + ], + retired: [], +}; diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 506858418..74acbdb46 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -4,7 +4,8 @@ * * · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[] * · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개) - * · 항목 꼴 = 고르기 · 숫자 · 자동(계산이 채움 · 저장 안 함) · 맞물림(길이 = 전 + 후) + * · 항목 꼴 = 고르기 · 숫자 · 글(손 입력 · 길이 상한 `TEXT_MAX`) · 자동(계산이 채움 · 저장 안 함) · + * 맞물림(길이 = 전 + 후) * · 상위 값에 따른 선택지 · 저장 키 = `by`(같은 컨테이너 안 상위 항목 값 → 갈래) * · 저장 = 정본 자리 옵션 그대로(관이면 pipe_points.json `options`) — 필수 컨테이너는 항목 `key` · * 선택 컨테이너는 인스턴스마다 `{prefix}_{n}_{항목}`(n 은 1부터) @@ -31,7 +32,7 @@ export type SplitRole = "length" | "before" | "after"; export interface SpecItem { id: string; label: string; - kind: "select" | "number" | "auto"; + kind: "select" | "number" | "text" | "auto"; /** 저장 키(필수 컨테이너 · `by` 없을 때). */ key?: string; choices?: SpecChoice[]; @@ -64,6 +65,9 @@ export interface StructureSpec { retired: string[]; } +/** 글 항목 길이 상한(자) — 칸 · 저장이 함께 자름. */ +export const TEXT_MAX = 100; + /** 컨테이너 인스턴스 한 벌 값 `{항목 id: 글}`. */ export type Instance = Record; export type SpecState = Record; @@ -135,7 +139,8 @@ export function readState( return state; } -/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김). */ +/** 컨테이너 값 → 옵션(빈 칸 뺌 · 숫자 칸은 수 · 선택 컨테이너는 차례대로 1 부터 다시 매김 · + * 다 빈 선택 인스턴스는 첫 칸을 빈 글로 남겨 자리를 지킴 — 잇기 · 집계 줄 · 다시 열기). */ export function writeOptions( spec: StructureSpec, state: SpecState, @@ -143,6 +148,7 @@ export function writeOptions( const out: Record = {}; for (const c of spec.containers) { (state[c.id] ?? []).forEach((values, index) => { + const before = Object.keys(out).length; for (const item of c.items) { const key = keyOf(c, item, index + 1, values); const raw = (values[item.id] ?? "").trim(); @@ -152,10 +158,13 @@ export function writeOptions( if (Number.isFinite(n)) out[key] = Math.round(n * 100) / 100; continue; } - out[key] = raw; + out[key] = item.kind === "text" ? raw.slice(0, TEXT_MAX) : raw; if (c.required) Object.assign(out, choicesOf(item, values).find((ch) => ch.value === raw)?.also); } + const first = c.items.find((item) => item.kind !== "auto"); + if (!c.required && first && Object.keys(out).length === before) + out[instanceKey(c, index + 1, first)] = ""; }); } return out; diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 1021a017c..dd92a28fd 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -90,8 +90,11 @@ def collect_items( 한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장, summary_item, 복사본}` · - `복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b). + `복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b) · + 기타는 `그룹` = 사용자가 적은 상세 종류 이름(52-14). """ + from M02_MasterTemplete.M02_Table_Fill_Spec import etc_label + types = types or structure_type_map() lengths = pipe_lengths or {} items: list[dict[str, Any]] = [] @@ -111,6 +114,7 @@ def collect_items( "summary_item": item.summary_item, "복사본": item.structure_id or f"#{index}", "group_items": item.group_items, + **etc_label(item.type_id, item.options), } ) for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index 2de0d16c3..41f9d1ec5 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -4,7 +4,9 @@ 집계 항목 = 잇기 `group_items[{잇기}{n}]` · 복사본 `{구조물 id}__{잇기}{n}` · 옵션 = 그 컨테이너 값. 열 바인딩이 읽는 칸이 갈래마다 달라(독립 기슭막이 `form` · `length_m` … · 관 `{유입|유출}_revet_*`) 컨테이너 값을 그 칸들에 함께 얹음 — 어느 출처로 읽어도 그 컨테이너 값. -짝: `A00_Common/design/design_structure_pipe.ts` 선택 컨테이너(머리 · 잇기 · 제목) — 시험이 대조. +짝: `A00_Common/design/design_structure_pipe.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) — +시험이 대조. +기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」. """ from __future__ import annotations @@ -20,8 +22,16 @@ SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { ("soil_add", "gsoiladd", "흙막이 추가"), ("chute_add", "gchuteadd", "도수로/돌붙임"), ), + "etc": (("etc_detail", "getcdetail", "상세 내용"),), } -_TEXT = ("structure", "form") +_TEXT = ("structure", "form", "note") + + +def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]: + """기타 집계 건 이름 `{그룹: 상세 종류 이름}`(비면 「기타」) — 다른 종류는 빈 묶음.""" + if type_id != "etc": + return {} + return {"그룹": str((options or {}).get("name") or "").strip() or "기타"} def _as_options(values: dict[str, Any]) -> dict[str, Any]: @@ -57,7 +67,7 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str "options": {**item["options"], **_as_options(found[n])}, "summary_item": links.get(gid), "복사본": f"{item['복사본']}{GROUP_JOIN}{gid}", - "그룹": f"{title} {n}", + "그룹": f"{item.get('그룹', title)} {n}", } ) return rows diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts index 63157ee50..699040835 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec.ts @@ -19,6 +19,7 @@ const TAG: Record, OptionsTextKey> = { optional: "SpecOptional", select: "FormSelect", number: "FormNumber", + text: "FormText", auto: "FormAuto", }; diff --git a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts index bd6bc693b..82ecf705d 100644 --- a/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts +++ b/M02_MasterTemplete/StructOptions/M02_SO_Def_Spec_Model.ts @@ -22,7 +22,7 @@ export interface SpecNameRow { /** 지금 이름 */ label: string; /** 읽기만 표시 — 필수/선택 · 꼴 */ - tag: "required" | "optional" | "select" | "number" | "auto" | null; + tag: "required" | "optional" | "select" | "number" | "text" | "auto" | null; /** 필수 컨테이너 제목이 정의 그룹 이름에서 옴 — 고치면 그 그룹 이름에 씀 */ group: string | null; } diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index 3d063ce2c..fcfb65245 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -56,6 +56,33 @@ if (process.argv[2] === "road") { process.exit(0); } +// 기타(PLAN 52-14) — 선택 컨테이너 「상세 내용」 비고 글 · 길이 상한 · 비우기 · 이름 표 +if (process.argv[2] === "etc") { + const spec = STRUCTURE_SPECS.etc; + const old = { name: "배수판", etc_detail_2_note: "옛 비고", etc_detail_5_note: "둘째" }; + const state = s.readState(spec, old); + const edited = JSON.parse(JSON.stringify(state)); + edited.detail[0].note = " 새 비고 "; + edited.detail[1].note = ""; + edited.detail.push({ note: "가".repeat(s.TEXT_MAX + 30) }); + const out = s.writeOptions(spec, edited); + process.stdout.write( + JSON.stringify({ + same: spec === road.ETC_SPEC, + containers: spec.containers.map((c) => [c.id, c.title, c.required, c.prefix, c.link]), + items: spec.containers[0].items.map((i) => [i.id, i.label, i.kind]), + state, + edited: out, + max: s.TEXT_MAX, + longLen: out.etc_detail_3_note?.length ?? 0, + keys: s.specKeys(spec), + names: s.nameTable(spec), + links: [1, 2].map((n) => s.linkOf(spec.containers[0], n)), + }), + ); + process.exit(0); +} + if (process.argv[2] === "names") { process.stdout.write(JSON.stringify(s.nameTable(PIPE_SPEC))); process.exit(0); diff --git a/resources/tester/test_52_14_etc_text.py b/resources/tester/test_52_14_etc_text.py new file mode 100644 index 000000000..beab8fabc --- /dev/null +++ b/resources/tester/test_52_14_etc_text.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +"""공용 틀 글 손 입력 · 기타 선택 컨테이너 · 기타 이름 = 집계 건 이름 — PLAN 52-14. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs etc`)가 돌림 · +화면은 ORCA `ui_checks/52-10_노면공_기타.js`(52-14 덧). +""" + +import json +import os +import re +import subprocess + +import pytest + +from M02_MasterTemplete import M02_Table_Fill as fill +from M02_MasterTemplete.M02_Table_Fill_Spec import SPEC_CONTAINERS, container_rows, etc_label +from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물 + MS, + RV, + _save, + world, +) + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), "etc"], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_기타_선택_컨테이너_상세_내용(out): + assert out["same"] is True + assert out["containers"] == [["detail", "상세 내용", False, "etc_detail", "getcdetail"]] + assert out["items"] == [["note", "비고", "text"]] + assert out["links"] == ["getcdetail1", "getcdetail2"] + + +def test_글_저장_비우기_길이_상한(out): + assert out["state"] == {"detail": [{"note": "옛 비고"}, {"note": "둘째"}]} # n 차례대로 당김 + edited = out["edited"] + assert edited["etc_detail_1_note"] == "새 비고" # 앞뒤 빈칸 뺌 + assert edited["etc_detail_2_note"] == "" # 비운 인스턴스도 자리를 지킴(잇기 · 집계 줄) + assert out["longLen"] == out["max"] == 100 + assert out["keys"] == {"keys": [], "prefixes": ["etc_detail_"]} + + +def test_이름_표에_글_항목(out): + assert out["names"] == {"detail": "상세 내용", "detail.note": "비고"} + + +def test_기타_이름이_집계_건_이름(): + assert etc_label("etc", {"name": " 배수판 "}) == {"그룹": "배수판"} + assert etc_label("etc", {"name": ""}) == {"그룹": "기타"} + assert etc_label("etc", {}) == {"그룹": "기타"} + assert etc_label("pipe", {"name": "x"}) == {} + item = { + "type_id": "etc", + "options": {"name": "배수판", "etc_detail_1_note": "왼쪽", "etc_detail_2_note": ""}, + "복사본": "st1", + "그룹": "배수판", + } + rows = container_rows(item, {"getcdetail1": RV, "getcdetail2": MS}) + assert [r["그룹"] for r in rows] == ["배수판 1", "배수판 2"] + assert [r["복사본"] for r in rows] == ["st1__getcdetail1", "st1__getcdetail2"] + assert [r["summary_item"] for r in rows] == [RV, MS] + assert rows[0]["options"]["note"] == "왼쪽" + + +def test_집계_모음에_기타_이름과_컨테이너_건(world): # noqa: F811 — 고정물 + root = world["root"] + etc = { + "structure_id": "etc1", + "type_id": "etc", + "placement": "point", + "chainage_m": 60.0, + "options": {"name": "배수판", "etc_detail_1_note": "왼쪽"}, + "group_items": {"getcdetail1": RV}, + } + blank = {**etc, "structure_id": "etc2", "chainage_m": 80.0, "options": {"name": ""}} + _save(root, [etc, blank], []) + items = {i["복사본"]: i for i in fill.collect_items(root)} + assert items["etc1"]["그룹"] == "배수판" + assert items["etc1__getcdetail1"]["그룹"] == "배수판 1" + assert items["etc1__getcdetail1"]["summary_item"] == RV + assert items["etc2"]["그룹"] == "기타" + + +def test_서버_컨테이너_표는_기타_명세와_짝(): + with open( + os.path.join(ROOT, "A00_Common/design/design_structure_road.ts"), encoding="utf-8" + ) as f: + text = f.read() + found = re.findall( + r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text + ) + assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["etc"]) diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js index 4ec66b9e8..b7d2f5f81 100644 --- a/resources/tester/ui_checks/52-10_노면공_기타.js +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -10,7 +10,9 @@ * 기대 수치: * 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 ["콘크리트"] · 높이 0.35 · 잇기 지정 · * 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · 길이 · 전 · 후 칸 보임 - * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · 종류 단위 잇기 줄 보임 · 지정 + * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · + * 선택 컨테이너 「상세 내용」(52-14) 셋 추가 · 비고 글(길이 상한 100) · 1 · 3 잇기 · 2 빼기 → + * 둘 남음(비고 ["52-14 비고", ""] · 잇기 둘 다 · 3 의 잇기가 2 로 당겨짐) · 종류 단위 잇기 줄 가림 * verify — 같은 값 · 같은 모양(B05 = B06) */ (async () => { @@ -75,6 +77,13 @@ paveSpec: specCtl("spec")?.value ?? null, height: specCtl("height")?.value ?? null, containerLink: !!boxes[0]?.querySelector(".b05-structure__summary-name.is-assigned"), + containerLinks: boxes.map( + (b) => !!b.querySelector(".b05-structure__summary-name.is-assigned"), + ), + notes: boxes.map( + (b) => b.querySelector('[data-spec-item="detail.note"] input')?.value ?? null, + ), + noteMax: spec()?.querySelector('[data-spec-item="detail.note"] input')?.maxLength ?? null, typeSummaryVisible: vis(typeSummary()), typeSummaryAfterPosition: after === typeSummary(), typeLink: !!typeSummary().querySelector(".b05-structure__summary-name.is-assigned"), @@ -152,7 +161,28 @@ input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); await w(200); - row.picked = await linkFirstLeaf(typeSummary().querySelector('[data-b05-summary="pick"]')); + // 52-14 — 상세 내용 셋 추가 · 비고 · 1 · 3 잇기 · 2 빼기 + for (let k = 0; k < 3; k++) { + spec().querySelector('[data-spec-add="detail"]').click(); + await w(200); + } + const notes = () => [...spec().querySelectorAll('[data-spec-item="detail.note"] input')]; + for (const [k, text] of [ + [0, "52-14 비고"], + [1, "지울 것"], + ]) { + notes()[k].value = text; + notes()[k].dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + } + const pickBtns = () => [ + ...spec().querySelectorAll('[data-spec-link] [data-b05-summary="pick"]'), + ]; + row.picked = await linkFirstLeaf(pickBtns()[0]); + row.picked3 = await linkFirstLeaf(pickBtns()[2]); + row.addedShape = shape(item.typeId); + spec().querySelectorAll('[data-spec-remove="detail"]')[1].click(); + await w(300); } Object.assign(row, shape(item.typeId), { phase: "edit" }); sec().querySelector(".is-primary").click(); // 추가 @@ -207,9 +237,11 @@ !!etc && !etc.missing && etc.name === NAME && - etc.containers.length === 0 && - etc.typeSummaryVisible && - etc.typeSummaryAfterPosition && - etc.typeLink; + JSON.stringify(etc.containers) === '["detail","detail"]' && + JSON.stringify(etc.legend) === '["상세 내용 1","상세 내용 2"]' && + JSON.stringify(etc.notes) === '["52-14 비고",""]' && + JSON.stringify(etc.containerLinks) === "[true,true]" && + etc.noteMax === 100 && + !etc.typeSummaryVisible; return JSON.stringify(out); })(); diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index 8d0a7cc51..f0f72fee0 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -39,7 +39,9 @@ /** 구조물집계표 채움 줄 수(전구간 고정 줄 뺌) — 52-1b. */ const filledRows = async () => (await m02Api.readFilled(projectId, "구조물집계표")).문서.줄.filter((r) => !r.고정).length; - const spec = () => document.querySelector(".b05-structure__spec"); + // 명세 폼은 둘(관 시설 · 일반 구조물 52-10) — 보이는 것 + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); const box = (c, i = 0) => spec().querySelectorAll(`[data-spec-container="${c}"]`)[i]; const ctl = (c, item, i = 0) => box(c, i)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input"); From 04ba81618625d721b3fcacb78d3156b9aaa1c854 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 00:10:15 +0900 Subject: [PATCH 19/37] =?UTF-8?q?feat(B05):=20=EB=AC=BC=EB=84=98=EC=9D=B4?= =?UTF-8?q?=20=ED=8F=AC=EC=9E=A5=20=3D=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=ED=91=9C=2003=20=EB=AA=85=EC=84=B8(52-3)=20=E2=80=94=20?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=EA=B0=81=EB=8F=84=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EC=88=98=EB=A1=9C/=EB=8F=8C=EB=B6=99=EC=9E=84=20=EC=BB=A8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세 design_structure_ford_pavement — 경사각도(필수 · 도 손 입력 · ford_slope_deg) · 도수로/돌붙임(선택 · ford_chute_{n}_structure) - B05 · B06 시설 폼이 명세 폼으로 그림 · 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 = 지난 정의 값(병합이 보존) - 잇기 — 정의 그룹 gfordpaveslope(경사각도 · 판 10) · 집계표 선택 컨테이너 gfordpavechute{n} - 경사각도는 옛 바닥 경사(%) 계산에 잇지 않음 — 뜻 애매 - 시험 test_52_3 · 화면 ui_checks/52-3_물넘이포장_컨테이너.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../design/design_structure_ford_pavement.ts | 46 + A00_Common/design/design_structure_pipe.ts | 2 + .../B05_Profile_UI_Drainage_Facility.ts | 41 +- .../B05_Profile_UI_Drainage_Facility_Merge.ts | 5 +- M02_MasterTemplete/M02_Table_Fill_Spec.py | 1 + resources/master_template/구조물정의.json | 10 +- .../master_template/구조물정의_이력/판_9.json | 3372 +++++++++++++++++ .../test_52_3_structure_spec_ford_pavement.py | 126 + .../ui_checks/52-3_물넘이포장_컨테이너.js | 206 + 9 files changed, 3780 insertions(+), 29 deletions(-) create mode 100644 A00_Common/design/design_structure_ford_pavement.ts create mode 100644 resources/master_template/구조물정의_이력/판_9.json create mode 100644 resources/tester/test_52_3_structure_spec_ford_pavement.py create mode 100644 resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js diff --git a/A00_Common/design/design_structure_ford_pavement.ts b/A00_Common/design/design_structure_ford_pavement.ts new file mode 100644 index 000000000..f9ca5257c --- /dev/null +++ b/A00_Common/design/design_structure_ford_pavement.ts @@ -0,0 +1,46 @@ +/* ============================================================================= + * design_structure_ford_pavement.ts + * 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로. + * + * 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임). + * 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure`. + * 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음. + * import 없음 — 시험이 이 파일만 옮겨 돌림. + * ========================================================================== */ + +import type { StructureSpec } from "./design_structure_spec"; + +export const FORD_PAVEMENT_SPEC: StructureSpec = { + typeId: "ford_pavement", + containers: [ + { + id: "slope", + title: "경사각도", + required: true, + link: "gfordpaveslope", + items: [ + { id: "angle", label: "경사각도 (°)", kind: "number", key: "ford_slope_deg", step: 0.1 }, + ], + }, + { + id: "chute", + title: "도수로/돌붙임", + required: false, + prefix: "ford_chute", + link: "gfordpavechute", + items: [ + { + id: "structure", + label: "구조", + kind: "select", + choices: [ + { value: "도수로", label: "도수로" }, + { value: "돌붙임", label: "돌붙임" }, + ], + }, + ], + }, + ], + // 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값) + retired: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], +}; diff --git a/A00_Common/design/design_structure_pipe.ts b/A00_Common/design/design_structure_pipe.ts index f00fef9ad..2eb57f801 100644 --- a/A00_Common/design/design_structure_pipe.ts +++ b/A00_Common/design/design_structure_pipe.ts @@ -13,6 +13,7 @@ import type { SpecChoice, SpecItem, StructureSpec } from "./design_structure_spec"; import { FORD_SPEC } from "./design_structure_ford"; +import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement"; import { PAVEMENT_SPEC } from "./design_structure_road"; const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이 @@ -189,5 +190,6 @@ export const PIPE_SPEC: StructureSpec = { export const STRUCTURE_SPECS: Readonly> = { pipe: PIPE_SPEC, ford_bridge: FORD_SPEC, + ford_pavement: FORD_PAVEMENT_SPEC, pavement_concrete: PAVEMENT_SPEC, }; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 3b540ee46..5d490055d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -62,7 +62,7 @@ import { type GroupItems, type SummaryItem, } from "../common_util/common_util_summary_item"; -import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford"; +import { FORD_PAVEMENT_SPEC } from "../A00_Common/design/design_structure_ford_pavement"; import { createHiddenOptions } from "./B05_Profile_UI_Drainage_Facility_Hidden"; export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로 @@ -395,9 +395,9 @@ export function createFacilityOptionsForm( boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM; } - // ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford`). - // 기본값(등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴 — 제안값 단추 없음(PLAN 46-7). - const ford = createFordFields(() => emit()); + // ── 물넘이 포장 = 사용자 구조물 표 03 명세(PLAN 52-3) — 경사각도 · 도수로/돌붙임 · + // 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ── + const paveForm = createSpecForm(specLinks, { onChange: () => emit() }); // 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정). const saveNote = document.createElement("p"); @@ -411,10 +411,7 @@ export function createFacilityOptionsForm( saveNote, pipeForm.root, fordForm.root, - ford.widthRow, - ford.slopeRow, - ford.countRow, - ford.summary, + paveForm.root, extraGroup.root, revetTopRow, revetInlet.root, @@ -441,12 +438,7 @@ export function createFacilityOptionsForm( wingOutFields.root.hidden = !hasWing; (isBridge ? fordForm.slot("inlet") : wingInHome).append(wingInFields.slot); (isBridge ? fordForm.slot("outlet") : wingOutHome).append(wingOutFields.slot); - const isFord = current === "ford_pavement"; - ford.countRow.hidden = true; - ford.widthRow.hidden = !isFord; - // 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다. - ford.slopeRow.hidden = current !== "ford_pavement"; - ford.summary.hidden = !isFord; + paveForm.root.hidden = current !== "ford_pavement"; const isRevet = current === "revetment"; revetTopRow.hidden = !isRevet; // 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임). @@ -458,7 +450,6 @@ export function createFacilityOptionsForm( // 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고, // 선택이 풀리면 스스로 사라진다. extraGroup.root.hidden = extraSlot.childElementCount === 0; - if (isFord) ford.sync(); if (isBox) syncBoxSize(); if (hasWing) { wingInFields.syncVisibility(); @@ -474,14 +465,9 @@ export function createFacilityOptionsForm( revetInlet.fields.onChange(emit); revetOutlet.fields.onChange(emit); revetSide.addEventListener("change", emit); - [ - boxSize, - boxWidth, - boxHeight, - ...wingInFields.inputs, - ...wingOutFields.inputs, - ...ford.inputs, - ].forEach((input) => input.addEventListener("change", emit)); + [boxSize, boxWidth, boxHeight, ...wingInFields.inputs, ...wingOutFields.inputs].forEach((input) => + input.addEventListener("change", emit), + ); /** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */ let inletStructureHandler: ((value: InletStructureKind) => void) | null = null; @@ -512,10 +498,12 @@ export function createFacilityOptionsForm( linkSource = links; pipeForm.paintLinks(); fordForm.paintLinks(); + paveForm.paintLinks(); }, paintLinks: () => { pipeForm.paintLinks(); fordForm.paintLinks(); + paveForm.paintLinks(); }, setChainage(chainageM) { chainage = chainageM; @@ -530,7 +518,6 @@ export function createFacilityOptionsForm( setFacility(facility, options = {}, designFlow = null, definition = null) { current = facility; hiddenOptions.remember(options); - ford.setDesignFlow(designFlow ?? null); fordFlow = designFlow ?? null; if (facility === null) { syncVisibility(); @@ -544,6 +531,8 @@ export function createFacilityOptionsForm( pipeForm.write(PIPE_SPEC, options, definition?.spec_names ?? {}, groups); if (facility === "ford_bridge") fordForm.write(FORD_SPEC, withLegacyGuards(options), definition?.spec_names ?? {}, groups); + if (facility === "ford_pavement") + paveForm.write(FORD_PAVEMENT_SPEC, options, definition?.spec_names ?? {}, groups); // 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다. // 값이 없으면 「안 정함」. const savedWidth = Number.parseFloat(text("body_width_m")); @@ -564,7 +553,6 @@ export function createFacilityOptionsForm( boxHeight.value = hasSaved ? String(savedHeight) : ""; wingInFields.write(options); wingOutFields.write(options); - if (facility === "ford_pavement") ford.write(facility, options); revetSide.value = text("side"); const spread = legacyRevetOptions(options); revetInlet.fields.write(spread); @@ -572,7 +560,6 @@ export function createFacilityOptionsForm( syncVisibility(); }, setDesignFlow(designFlow) { - ford.setDesignFlow(designFlow ?? null); fordFlow = designFlow ?? null; if (current === "ford_bridge") fordForm.refresh(); // 높이 안내 syncVisibility(); @@ -589,7 +576,7 @@ export function createFacilityOptionsForm( wingInFields.read(options); wingOutFields.read(options); } else if (current === "ford_pavement") { - ford.read(current, options); + Object.assign(options, paveForm.read()); // 표 03 명세 값 · 옛 월류 · 경사 · 두께 · 길이는 병합이 이어 붙임(52-3) } else if (current === "ford_bridge") { Object.assign(options, fordForm.read()); // 표 04 명세 값 · 옛 관종 · 날개벽 치수는 병합이 이어 붙임(52-4) } else if (current === "revetment") { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 51b56df66..e03f66703 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -41,7 +41,9 @@ export const FORM_OPTION_KEYS: Readonly> = { ...revetKeys("outlet"), ], box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")], - ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"], + // 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) — 옛 월류 · 경사 · 두께 · 길이는 + // 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES` + ford_pavement: ["ford_slope_deg"], // 세월교 = 표 04 명세 키(PLAN 52-4 · `design_structure_ford`) — 옛 관종 · 날개벽 치수는 폼에서 빠져 이어 붙임 ford_bridge: [ "pipe_diameter_mm", @@ -59,6 +61,7 @@ export const FORM_OPTION_KEYS: Readonly> = { /** 폼이 인스턴스 번호를 붙여 적는 키 머리(명세 선택 컨테이너 · `{머리}{n}_{항목}`) — 빼면 지워짐. */ export const FORM_OPTION_PREFIXES: Readonly> = { pipe: ["soil_add_", "chute_add_"], + ford_pavement: ["ford_chute_"], }; /** 독립 기슭막이 레지스트리 키 → 폼은 유입·유출 두 채널 키로 적는다. */ diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index 2de0d16c3..f363eac2b 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -20,6 +20,7 @@ SPEC_CONTAINERS: dict[str, tuple[tuple[str, str, str], ...]] = { ("soil_add", "gsoiladd", "흙막이 추가"), ("chute_add", "gchuteadd", "도수로/돌붙임"), ), + "ford_pavement": (("ford_chute", "gfordpavechute", "도수로/돌붙임"),), } _TEXT = ("structure", "form") diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 83644f8a7..9672959bb 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 9, + "판": 10, "schema_version": 1, "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", "구조물군": { @@ -3365,6 +3365,14 @@ ], "형상": {} } + ], + "ford_pavement": [ + { + "id": "gfordpaveslope", + "이름": "경사각도", + "옵션": [], + "형상": {} + } ] }, "배치": {}, diff --git a/resources/master_template/구조물정의_이력/판_9.json b/resources/master_template/구조물정의_이력/판_9.json new file mode 100644 index 000000000..83644f8a7 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_9.json @@ -0,0 +1,3372 @@ +{ + "판": 9, + "schema_version": 1, + "comment": "B05 구조물 타입 레지스트리 정본. 근거·옵션 상세는 PLAN.md 구조물 리스트와 resources/knowledge/technical_info/01_임도 참조. 2026-08-17 선택지 재편: B군(종단배수)·F군(생태/녹화)·G군(노면공)은 enabled:false 보존 — B05 선택지에서 빠지고 B06 개별 횡단도 옵션으로 재사용한다. 바닥막이(bed_sill)·생태연못(eco_pond)은 임도 미사용 확정으로 삭제. 배관·BOX암거·물넘이·세월교는 pipe_points.json 정본 관리(managed_by) — structures.json에 저장하지 않으며, 배관 부속(유형/집수정/기슭막이/돌붙임)·날개벽 옵션 정의는 B06/B07 필수 승격의 선언 근거다. ★기본값 원칙(2026-08-16 크로스체크 2차): default를 두는 것은 ①법정이 등급과 무관하게 못박은 단일 수치 ②사용자가 개발 단계에서 확정한 값 ③도메인 수치가 아닌 표시용 문자열, 이 셋뿐이다. 재료·형식 같은 설계자 판단 선택지와 미확정 제원은 default 없이 required로 두어 사용자가 직접 고르게 한다 (pipe_diameter_mm 1000 = 별표2 원칙값+사용자 확정 — 화이트리스트 등재). BOX암거 본체 2.0×2.0·날개벽 유입/유출 있음·짧은쪽 높이 1m·길이 2m·각도 45°는 2026-08-17 사용자 확정값이다 — 본체 규격은 실무 관측 규격(2.0×2.0·3.0×3.0)을 폼에서 프리셋 select로 받고 필요 시 사용자 지정으로 자유 입력하되, 저장 키는 body_width_m·body_height_m 숫자 그대로다(프리셋 전용 키를 만들지 않는다). 이 값들은 B05 서브폼이 실제로 받으므로 phase를 detail로 두지 않는다. ★보호공 개편(2026-08-17 사용자 지시): 구 `*_pitching`(있음/없음) + `*_pitching_finish`(찰/메) 두 축을 `*_protection` 한 축(돌붙임(찰)·돌붙임(메)·도수로)으로 합쳤다 — 도수로·산비탈수로(B4)는 유출부에서 돌붙임 대신 쓰는 공종이라 별도 구조물이 아니라 보호공 선택지로 받는다. \"없음\"은 삭제(물이 흐르는 자리라 보호공 필수). 치수는 배타 — 돌붙임이면 `*_protection_area_m2`(10㎡), 도수로면 `*_protection_width_m`(1m, 지식DB 수치 근거 없음·사용자 확정, 실무 확인 후 개선). 구 저장분은 `common_util_drainage_pipes.parse_pipe_points`가 새 키로 옮긴다.", + "구조물군": { + "A": "A 횡단배수", + "B": "B 종단배수", + "C": "C 사면안정", + "D": "D 계류·사방", + "E": "E 안전·부대·용지", + "F": "F 생태·녹화", + "G": "G 노면공", + "호환": "기타" + }, + "동작": { + "계곡통과시설": "관 지점 정본(pipe_points.json) 시설 — 부속 옵션 서브폼을 손코딩으로 그림 · 저장은 폼이 아는 칸만 갈아 끼움", + "유입유출_묶음": "관 유입구(집수정 · 기슭막이 택일) · 유출구(기슭막이) 소그룹", + "기슭막이_한벌": "기슭막이 형태 · 길이 · 높이 · 바닥 보호공 한 벌 — 관 유입 · 유출 · 독립 기슭막이가 같은 조각", + "BOX_본체규격": "BOX암거 본체 규격 프리셋 고르기(폭 × 높이 한 칸)", + "날개벽": "날개벽 유입 · 유출 · 짧은쪽 높이 · 길이 · 각도 조각", + "월류단면": "월류 폭 · 높이 · 바닥 경사 · 포장 두께 + 개략 단면 줄 · 필요 수심 미만은 되돌림", + "포장_물넘이_겹침": "포장 구간이 물넘이포장을 품으면 앞 · 뒤로 나눔 · 끝만 걸치면 저장 멈춤" + }, + "types": [ + { + "type_id": "pipe", + "group": "A", + "name": "배관", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#4f8ef7", + "abbr": "배수관" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false, + "enabled": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "inlet_type", + "label": "유입구 구조", + "input": "select", + "choices": [ + "기슭막이", + "집수정" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "wing_wall_type", + "label": "관보호공 날개벽", + "input": "select", + "choices": [ + "A-TYPE", + "C-TYPE", + "A-TYPE+집수정" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 날개벽을 안 셈 — 고르면 그 형식의 개소당 원단위로 한 줄이 섬(Ø800·Ø1000 만 있음)" + }, + { + "key": "inlet_basin_form", + "label": "집수정 형식", + "input": "select", + "choices": [ + "동물이동형", + "□형(기본형)", + "돌집수정 ㄴ형", + "돌집수정 ㄷ형" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_material", + "label": "집수정 형태", + "input": "select", + "choices": [ + "콘크리트", + "조립식 주철맨홀", + "돌쌓기(찰)", + "돌쌓기(메)" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_length_m", + "label": "집수정 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_before_m", + "label": "집수정 기준측점 전", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_basin_after_m", + "label": "집수정 기준측점 후", + "input": "number", + "unit": "m", + "default": 1, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_form", + "label": "유입 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(찰)", + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_length_m", + "label": "유입 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "inlet_revet_before_m", + "label": "유입 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_after_m", + "label": "유입 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "inlet_revet_height_m", + "label": "유입 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "outlet_type", + "label": "유출구 구조", + "input": "select", + "choices": [ + "기슭막이" + ], + "default": "기슭막이", + "required": false + }, + { + "key": "outlet_revet_form", + "label": "유출 기슭막이 형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_length_m", + "label": "유출 기슭막이 길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false + }, + { + "key": "outlet_revet_before_m", + "label": "유출 기슭막이 기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_after_m", + "label": "유출 기슭막이 기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail" + }, + { + "key": "outlet_revet_height_m", + "label": "유출 기슭막이 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)" + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + } + ] + }, + { + "type_id": "box_culvert", + "group": "A", + "name": "BOX암거", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#3b6fd4", + "abbr": "암거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "body_width_m", + "label": "본체 폭", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "revet_foundation", + "label": "기슭막이 기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": false, + "phase": "detail" + }, + { + "key": "body_height_m", + "label": "본체 높이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "length_m", + "label": "연장(계류 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 수량이 안 섬 — 구체 길이는 설계자 입력(기본값 없음 · 2026-09-15 브레인 판정 ③)" + }, + { + "key": "wall_thickness_m", + "label": "측벽 두께", + "input": "number", + "unit": "m", + "default": 0.3, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 벽 0.3 · KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "slab_thickness_m", + "label": "상·저판 두께", + "input": "number", + "unit": "m", + "default": 0.25, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 판 0.25 — ⚠ KDS 44 90 00 4.5.2 도로암거 부재 최소두께 300㎜ 에 못 미침(임도 예제 0.25 · KDS 도로 최소 300㎜) · 설계자가 고름", + "required": false, + "phase": "detail" + }, + { + "key": "haunch_m", + "label": "헌치(모따기) 크기", + "input": "number", + "unit": "m", + "default": 0.2, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 0.2×0.2 · 실무 울진 기번3 구조도 2×2 도 0.2 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "blinding_thickness_m", + "label": "기초(버림) 두께", + "input": "number", + "unit": "m", + "default": 0.1, + "default_basis": "제안값 · 산림과임업기술(임도) 5장 수량산출 예제 기초 0.1(양쪽 0.1 여유) · 실무 울진 기번3 구조도 2×2 버림 0.1 · 바꿀 수 있음", + "required": false, + "phase": "detail" + }, + { + "key": "trench_depth_m", + "label": "평균 터파기고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 구체 터파기·되메우기가 안 섬 — 원지반에서 터파기 바닥(기초잡석 밑)까지 · 토공집계로 감(2026-09-15 브레인 판정 ③)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "ford_pavement", + "group": "A", + "name": "물넘이포장", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#2f9e8f", + "abbr": "물넘이" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "detail", + "default_basis": "물넘이 월류 폭 기본 5m — 사용자 확정값(엔진 FORD_PAVEMENT_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "ford_slope_pct", + "label": "바닥 경사(유입→유출)", + "input": "number", + "unit": "%", + "default": null, + "required": false + }, + { + "key": "thickness_cm", + "label": "포장 두께", + "input": "number", + "unit": "㎝", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)" + }, + { + "key": "length_m", + "label": "포장 길이(노폭 방향)", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)" + } + ] + }, + { + "type_id": "ford_bridge", + "group": "A", + "name": "세월교", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#28857a", + "abbr": "세월교" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "pipe_kind", + "label": "관종", + "input": "select", + "choices": [ + "흄관", + "VR관", + "파형강관" + ], + "default": "파형강관", + "required": false + }, + { + "key": "pipe_diameter_mm", + "label": "관경", + "input": "select", + "choices": [ + "800", + "1000", + "1200", + "1500" + ], + "unit": "mm", + "default": "1000", + "required": false + }, + { + "key": "pipe_count", + "label": "배관 수량", + "input": "number", + "unit": "련", + "default": null, + "required": false + }, + { + "key": "ford_width_m", + "label": "월류 폭", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "detail", + "default_basis": "세월교 월류 폭 기본 10m — 사용자 확정값(엔진 FORD_DEFAULT_WIDTH_M 과 같음)" + }, + { + "key": "wing_in", + "label": "날개벽(유입)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_in_height_m", + "label": "유입 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_in_length_m", + "label": "유입 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_in_angle_deg", + "label": "유입 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + }, + { + "key": "wing_out", + "label": "날개벽(유출)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": "있음", + "required": false + }, + { + "key": "wing_out_height_m", + "label": "유출 날개벽 짧은쪽 높이", + "input": "number", + "unit": "m", + "default": 1, + "required": false + }, + { + "key": "wing_out_length_m", + "label": "유출 날개벽 길이", + "input": "number", + "unit": "m", + "default": 2, + "required": false + }, + { + "key": "wing_out_angle_deg", + "label": "유출 날개벽 각도", + "input": "number", + "unit": "°", + "default": 45, + "required": false + } + ] + }, + { + "type_id": "cross_drain_exposed", + "group": "A", + "name": "노출형 횡단수로", + "placement": "point", + "style": { + "color": "#5aa7e8", + "abbr": "횡수로" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "open_ditch", + "group": "A", + "name": "개거(겉도랑)", + "placement": "point", + "style": { + "color": "#6d9bd1", + "abbr": "개거" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [] + }, + { + "type_id": "cross_underdrain", + "group": "A", + "name": "맹암거", + "placement": "point", + "style": { + "color": "#3aa0a8", + "abbr": "횡맹" + }, + "drawing_views": [ + "plan", + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "culvert_height_m", + "label": "암거 높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + }, + { + "key": "culvert_slope_pct", + "label": "암거 경사", + "input": "number", + "unit": "%", + "default": null, + "required": false, + "empty_means": "비우면 값 없음 — 사용자 표 06 손 입력 칸(기본값 근거 없음)" + } + ] + }, + { + "type_id": "ditch_side", + "enabled": false, + "group": "B", + "name": "측구(옆도랑)", + "design_owner": "횡단 설계", + "placement": "interval", + "style": { + "color": "#2eaadc", + "abbr": "측구" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "일반형(제형)", + "L형", + "U형", + "J형" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "depth_cm", + "label": "깊이", + "input": "number", + "unit": "cm", + "default": 30 + } + ] + }, + { + "type_id": "ditch_ridge", + "enabled": false, + "group": "B", + "name": "산마루측구", + "placement": "interval", + "style": { + "color": "#2e8bdc", + "abbr": "산마루" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "콘크리트", + "돌", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "ditch_berm", + "enabled": false, + "group": "B", + "name": "소단측구", + "placement": "interval", + "style": { + "color": "#2ec5dc", + "abbr": "소단구" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "사면 쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "chute", + "enabled": false, + "group": "B", + "name": "도수로·산비탈수로", + "placement": "interval", + "style": { + "color": "#1f9bb8", + "abbr": "도수로" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌", + "콘크리트", + "블록판", + "벤치플륨관", + "반원관" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "section", + "label": "단면형", + "input": "select", + "choices": [ + "반달꼴", + "U자형", + "사다리꼴" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "slope_drain", + "enabled": false, + "group": "B", + "name": "절토사면 배수로", + "placement": "interval", + "style": { + "color": "#28b1a1", + "abbr": "사면로" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "material", + "label": "재료", + "input": "select", + "choices": [ + "떼", + "돌" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "underdrain", + "enabled": false, + "group": "B", + "name": "맹암거", + "placement": "interval", + "style": { + "color": "#1f7fb8", + "abbr": "맹암거" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "note", + "label": "규격", + "input": "text", + "default": "" + } + ] + }, + { + "type_id": "retaining_wall", + "group": "C", + "name": "옹벽", + "placement": "interval", + "style": { + "color": "#e07a2e", + "abbr": "옹벽" + }, + "drawing_views": [ + "profile", + "cross_section", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2, + "default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음", + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "중력식", + "반중력식", + "캔틸레버식", + "부벽식", + "식생옹벽블럭" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "masonry_wet", + "group": "C", + "name": "돌쌓기(찰)", + "placement": "interval", + "style": { + "color": "#d0692a", + "abbr": "찰쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤3.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "masonry_dry", + "group": "C", + "name": "돌쌓기(메)", + "placement": "interval", + "style": { + "color": "#c25d2c", + "abbr": "메쌓기" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)", + "warn_above": 2, + "warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "soil_guard", + "group": "C", + "name": "흙막이", + "placement": "interval", + "style": { + "color": "#b8742e", + "abbr": "흙막이" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "콘크리트", + "돌(찰)", + "돌(메)", + "블록쌓기", + "돌망태", + "흙포대", + "통나무쌓기", + "떼" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌)" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌)" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + } + ] + }, + { + "type_id": "boulder_masonry", + "group": "C", + "name": "큰돌쌓기", + "placement": "interval", + "style": { + "color": "#a86a2a", + "abbr": "큰돌" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 2.5, + "required": false, + "phase": "b05", + "empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "stone_cm", + "label": "돌규격", + "input": "select", + "choices": [ + "40~60", + "60~80", + "80~100" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bond", + "label": "쌓기 방식", + "input": "select", + "choices": [ + "메쌓기", + "찰쌓기" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "자동(성토 쪽)", + "좌", + "우" + ], + "default": "자동(성토 쪽)", + "default_basis": "「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」", + "not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "berm", + "group": "C", + "name": "소단", + "placement": "interval", + "style": { + "color": "#7f9a63", + "abbr": "소단" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "width_m", + "label": "폭", + "input": "number", + "unit": "m", + "default": 0.5, + "required": false, + "phase": "b05" + }, + { + "key": "interval_m", + "label": "간격(사면길이)", + "input": "number", + "unit": "m", + "default": 3, + "required": false, + "phase": "b05" + }, + { + "key": "slope_deg", + "label": "안쪽 기울기", + "input": "number", + "unit": "°", + "default": 0, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "erosion_check", + "group": "D", + "name": "골막이", + "placement": "point", + "style": { + "color": "#7a52c7", + "abbr": "골막이" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "돌", + "돌망태", + "콘크리트", + "통나무", + "바자", + "흙" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "spillway", + "label": "방수로", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "back_len_cm", + "label": "뒷길이 ℓ3", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "top_length_m", + "label": "상장 ⓐ (윗변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "bottom_length_m", + "label": "하장 ⓑ (아랫변)", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 4.5 + }, + { + "key": "height_m", + "label": "높이(≤2.0)", + "input": "number", + "unit": "m", + "default": 2 + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + } + ] + }, + { + "type_id": "bed_sill", + "group": "D", + "name": "바닥막이", + "placement": "point", + "style": { + "color": "#7c5f3b", + "abbr": "바닥" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌붙임(찰)", + "돌붙임(메)" + ], + "default": "돌붙임(메)", + "required": false, + "phase": "b05" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김", + "not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "area_m2", + "label": "면적", + "input": "number", + "unit": "㎡", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": 0.3, + "required": false, + "phase": "b05" + } + ] + }, + { + "type_id": "check_dam_small", + "group": "D", + "name": "소형사방댐(복합형)", + "placement": "point", + "style": { + "color": "#6b46b8", + "abbr": "사방댐" + }, + "drawing_views": [ + "plan", + "profile", + "detail", + "quantity" + ], + "options": [ + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revetment", + "group": "D", + "name": "기슭막이", + "placement": "point", + "managed_by": "pipe_points", + "style": { + "color": "#9b6bdc", + "abbr": "기슭" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": [ + "양쪽", + "좌", + "우" + ], + "default": "양쪽", + "required": false, + "phase": "b05", + "default_basis": "「좌」는 셀렉트 첫 항목일 뿐 도메인 확정값이 아님 — 측점마다 사용자가 고름" + }, + { + "key": "tiers", + "label": "단 수(다단)", + "input": "number", + "unit": "단", + "default": 1, + "required": false, + "phase": "b05", + "default_basis": "1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식" + }, + { + "key": "lift_m", + "label": "기준 올림(사면 위로)", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "shift_m", + "label": "기준 좌우 이동", + "input": "number", + "unit": "m", + "default": 0, + "required": false, + "phase": "b05", + "default_basis": "0 = 자동 자리 그대로" + }, + { + "key": "form", + "label": "형태", + "input": "select", + "choices": [ + "돌쌓기(찰)", + "돌쌓기(메)", + "콘크리트", + "돌망태", + "통나무·목재틀", + "바자" + ], + "default": "돌쌓기(메)", + "required": false, + "phase": "b05" + }, + { + "key": "thickness_top_m", + "label": "상부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김" + }, + { + "key": "thickness_bottom_m", + "label": "하부 두께", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김" + }, + { + "key": "back_len_cm", + "label": "뒷길이", + "input": "select", + "choices": [ + "25", + "30", + "35", + "45", + "55", + "60", + "75" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_kind", + "label": "돌 종류", + "input": "select", + "choices": [ + "야면석·호박돌", + "깬잡석", + "깬돌", + "견치돌" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검" + }, + { + "key": "stone_supply", + "label": "조달", + "input": "select", + "choices": [ + "채집", + "구입" + ], + "default": "채집", + "required": false, + "phase": "detail" + }, + { + "key": "stone_coeff_basis", + "label": "야면석 계수", + "input": "select", + "choices": [ + "품셈", + "실무 관행" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀" + }, + { + "key": "fill_concrete_mpa", + "label": "채움 강도", + "input": "select", + "choices": [ + "180", + "210" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 계산 쪽 기준 강도로 돎" + }, + { + "key": "face_slope_ratio", + "label": "전면 기울기 (1:n 의 n)", + "input": "number", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김" + }, + { + "key": "face_role", + "label": "성토/절토", + "input": "select", + "choices": [ + "성토", + "절토" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김" + }, + { + "key": "foundation", + "label": "기초", + "input": "select", + "choices": [ + "기초유", + "기초버림" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "height_m", + "label": "높이", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)" + }, + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10, + "required": false, + "phase": "b05" + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5, + "required": false, + "phase": "b05" + }, + { + "key": "weep_hole_diameter_mm", + "label": "물빼기 구멍 지름", + "input": "number", + "unit": "㎜", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "weep_hole_area_m2", + "label": "물빼기 구멍 1개당 벽면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」" + }, + { + "key": "blinding_concrete", + "label": "버림 콘크리트", + "input": "select", + "choices": [ + "넣음", + "안 넣음" + ], + "default": "넣음", + "default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜", + "required": false, + "phase": "detail", + "empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)" + } + ] + }, + { + "type_id": "refuge", + "group": "E", + "name": "대피소", + "placement": "interval", + "style": { + "color": "#2fa84f", + "abbr": "대피소" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 15 + } + ] + }, + { + "type_id": "work_yard", + "group": "E", + "name": "정차·작업장", + "placement": "interval", + "style": { + "color": "#268f43", + "abbr": "작업장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 7 + }, + { + "key": "length_m", + "label": "유효길이", + "input": "number", + "unit": "m", + "default": 20 + } + ] + }, + { + "type_id": "turnaround", + "group": "E", + "name": "차돌림곳", + "placement": "site", + "style": { + "color": "#37b95a", + "abbr": "차돌림" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "width_m", + "label": "너비", + "input": "number", + "unit": "m", + "default": 10 + } + ] + }, + { + "type_id": "mirror", + "group": "E", + "name": "반사경", + "placement": "point", + "style": { + "color": "#43c76b", + "abbr": "반사경" + }, + "drawing_views": [ + "plan" + ], + "options": [ + { + "key": "facing", + "label": "방향", + "input": "select", + "choices": [ + "좌", + "우", + "양면" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "guardrail", + "group": "E", + "name": "가드레일·경계석·위험표지", + "placement": "interval", + "style": { + "color": "#1f9e46", + "abbr": "가드" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "kind", + "label": "종류", + "input": "select", + "choices": [ + "가드레일", + "경계석", + "위험표지" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "barrier_gate", + "group": "E", + "name": "차단기", + "placement": "point", + "style": { + "color": "#3f9e2e", + "abbr": "차단기" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "completion_sign", + "group": "E", + "name": "준공표지판", + "placement": "point", + "style": { + "color": "#66b344", + "abbr": "준공" + }, + "drawing_views": [ + "plan" + ], + "options": [] + }, + { + "type_id": "spoil_bank", + "group": "E", + "name": "유용토운반작업장(사토장)", + "placement": "interval", + "style": { + "color": "#8c9a2e", + "abbr": "사토장" + }, + "drawing_views": [ + "plan", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "capacity_m3", + "label": "사토장 부피", + "input": "number", + "unit": "㎥", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 B06 사토 배분이 이 사토장을 건너뜀" + } + ] + }, + { + "type_id": "borrow_pit", + "group": "E", + "name": "토사채취장(토취장)", + "placement": "site", + "style": { + "color": "#7d8a2a", + "abbr": "토취장" + }, + "drawing_views": [ + "plan", + "quantity" + ], + "options": [ + { + "key": "capacity_m3", + "label": "토취장 부피", + "input": "number", + "unit": "㎥", + "default": null, + "required": false, + "phase": "b05", + "empty_means": "비우면 자리만 잡음 — 부피는 설계자가 현장 조사로 손 입력" + } + ] + }, + { + "type_id": "wildlife_path", + "enabled": false, + "group": "F", + "name": "야생동물 이동통로", + "placement": "point", + "style": { + "color": "#3aa88f", + "abbr": "동물길" + }, + "drawing_views": [ + "plan", + "cross_section" + ], + "options": [ + { + "key": "form", + "label": "형식", + "input": "select", + "choices": [ + "경사로", + "자연형계단" + ], + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "revegetation", + "enabled": false, + "group": "F", + "name": "비탈면 녹화", + "placement": "interval", + "style": { + "color": "#4cb84c", + "abbr": "녹화" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "method", + "label": "공법", + "input": "select", + "choices": [ + "씨뿌리기(줄파종)", + "새심기", + "선떼붙이기", + "줄떼", + "평떼", + "비탈덮기", + "녹생토 뿜어붙이기", + "조공", + "편책", + "나무심기" + ], + "default": null, + "default_basis": "「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인)", + "required": true, + "phase": "detail" + }, + { + "key": "face", + "label": "대상 면", + "input": "select", + "choices": [ + "절토면", + "성토면", + "양쪽" + ], + "default": null, + "required": true, + "phase": "detail" + }, + { + "key": "cover_kind", + "label": "덮개(비탈덮기)", + "input": "select", + "choices": [ + "거적", + "짚망", + "방초·야자섬유매트" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비탈덮기일 때만 고름 — 비면 비탈덮기 줄이 안 섬(품셈 5-25 거적덮기 ㎡ · 5-28-1 짚망 · 5-28-2 매트 · 재료가 공종을 가름)" + }, + { + "key": "step_height_m", + "label": "단 직고", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기·조공의 단 사이 직고 — 교본은 범위로만 줌(선떼 직고 1~2m · 조공 1.0~1.2m) · 기본값 없음 · 비면 선떼 연장(품셈 5-16-1 [주]② 대상지 직고 ÷ 단 직고 × 연장)이 안 섬" + }, + { + "key": "sod_grade", + "label": "선떼 급수", + "input": "select", + "choices": [ + "1급", + "2급", + "3급", + "4급", + "5급", + "6급", + "7급", + "8급", + "9급" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "선떼붙이기의 급수 — 교본은 1~9급 범위만(6~7급을 많이 씀) · 기본값 없음 · 비면 m당 떼 매수(사방기술교본 표 2-2-6)가 안 섬" + }, + { + "key": "sod_size", + "label": "선떼 떼 크기", + "input": "select", + "choices": [ + "대형 40×40", + "소형 18×18" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "사방기술교본 표 2-2-6 이 대형(40×40)·소형(18×18) 두 줄을 나란히 줌 — 우리가 고를 자리가 아니라 기본값 없음 · 비면 선떼 떼 매수가 안 섬" + }, + { + "key": "cutting_soil", + "label": "단끊기 토질", + "input": "select", + "choices": [ + "보통토사", + "경질ㆍ고사점토 및 자갈섞인 점토", + "호박돌 섞인 토사" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "품셈 5-16-1 단끊기 표의 토질 셋 — B06 지반(토사/암)과 다른 축이라 자동으로 안 고름 · 비면 단끊기 조각만 막힘" + } + ] + }, + { + "type_id": "pavement_concrete", + "group": "G", + "name": "콘크리트 포장", + "placement": "interval", + "style": { + "color": "#8a8f98", + "abbr": "포장" + }, + "drawing_views": [ + "profile", + "cross_section", + "quantity" + ], + "options": [ + { + "key": "length_m", + "label": "길이", + "input": "number", + "unit": "m", + "default": 10 + }, + { + "key": "before_m", + "label": "기준측점 전", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "after_m", + "label": "기준측점 후", + "input": "number", + "unit": "m", + "default": 5 + }, + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 포장층 두께(표준 횡단면 설정 · 기본 0.2m = 임도기술교본 3-2 「두께 0.2m」)를 이음 — 적으면 적은 값이 이김" + }, + { + "key": "width_m", + "label": "포장 폭", + "input": "number", + "unit": "m", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 노폭(구간 측점의 차도 표준 폭 · 교본 3-2 「포장 폭 3.0m」 · 실무 관측 3.5~4.0)을 이음 — 적으면 적은 값이 이김" + }, + { + "key": "widening_area_m2", + "label": "확폭 면적", + "input": "number", + "unit": "㎡", + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 B06 곡선부 확폭을 측점 사이 평균 × 거리로 더함(교본 3-2 「곡선부에는 확폭」) — 확폭이 없으면 0 을 적을 것" + }, + { + "key": "joint_spacing_m", + "label": "수축줄눈 간격", + "input": "number", + "unit": "m", + "default": 6, + "default_basis": "제안값 · 임도기술교본 부록 4-7 7-1 3.5.1 「수축줄눈의 간격은 4-6m를 기준」 · 실무 소광 6m 관측 · 바꿀 수 있음", + "phase": "detail" + }, + { + "key": "separation_sheet", + "label": "비닐(깔기·양생)", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 비닐 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「양생 재료비(비닐 등)는 별도 계상」이라 설계자가 고름(기본값 없음)" + }, + { + "key": "wire_mesh", + "label": "철망", + "input": "select", + "choices": [ + "있음", + "없음" + ], + "default": null, + "required": false, + "phase": "detail", + "empty_means": "비우면 철망 재료가 안 서고 사유가 뜸 — 품셈 12-6 [주]② 「철망재료비는 별도 계상」 · 교본 부록 7-1 3.4 「설계도서에 따라」 · 실무 소광 「철망포함」 관측" + } + ] + }, + { + "type_id": "gravel_surfacing", + "enabled": false, + "group": "G", + "name": "쇄석(자갈) 포설", + "placement": "interval", + "style": { + "color": "#9aa0a8", + "abbr": "쇄석" + }, + "drawing_views": [ + "cross_section", + "quantity" + ], + "options": [ + { + "key": "thickness_cm", + "label": "두께", + "input": "number", + "unit": "cm", + "default": null, + "required": true, + "phase": "detail" + } + ] + }, + { + "type_id": "etc", + "group": "호환", + "name": "기타", + "placement": "point", + "style": { + "color": "#98a2b3", + "abbr": "기타" + }, + "drawing_views": [ + "profile" + ], + "options": [ + { + "key": "name", + "label": "상세 종류 이름", + "input": "text", + "default": "기타 구조물" + } + ] + } + ], + "종류": { + "pipe": { + "동작": [ + "계곡통과시설", + "유입유출_묶음", + "기슭막이_한벌" + ], + "이름": { + "pipe.diameter": "관경", + "pipe.diameter=800": "800", + "pipe.diameter=1000": "1000", + "pipe.diameter=1200": "1200", + "pipe.diameter=1500": "1500", + "pipe.length": "관 길이 (m)", + "inlet.structure": "구조", + "inlet.structure=집수정": "집수정", + "inlet.structure=기슭막이": "흙막이", + "inlet.form": "형태", + "inlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "inlet.form=돌쌓기(메)": "돌 쌓기(메)", + "inlet.form=콘크리트": "콘크리트", + "inlet.form=돌망태": "돌망태", + "inlet.form=통나무·목재틀": "통나무/목재틀", + "inlet.form=바자": "바자", + "inlet.form=I": "I형", + "inlet.form=L": "L형", + "inlet.form=U": "ㄷ형", + "inlet.length": "길이 (m)", + "inlet.height": "높이 (m)", + "inlet.before": "기준측점 전 (m)", + "inlet.after": "기준측점 후 (m)", + "outlet.structure": "구조", + "outlet.structure=기슭막이": "흙막이", + "outlet.form": "형태", + "outlet.form=돌쌓기(찰)": "돌 쌓기(찰)", + "outlet.form=돌쌓기(메)": "돌 쌓기(메)", + "outlet.form=콘크리트": "콘크리트", + "outlet.form=돌망태": "돌망태", + "outlet.form=통나무·목재틀": "통나무/목재틀", + "outlet.form=바자": "바자", + "outlet.length": "길이 (m)", + "outlet.height": "높이 (m)", + "outlet.before": "기준측점 전 (m)", + "outlet.after": "기준측점 후 (m)", + "soil_add": "흙막이 추가", + "soil_add.structure": "구조", + "soil_add.structure=기슭막이": "흙막이", + "soil_add.form": "형태", + "soil_add.form=돌쌓기(찰)": "돌 쌓기(찰)", + "soil_add.form=돌쌓기(메)": "돌 쌓기(메)", + "soil_add.form=콘크리트": "콘크리트", + "soil_add.form=돌망태": "돌망태", + "soil_add.form=통나무·목재틀": "통나무/목재틀", + "soil_add.form=바자": "바자", + "soil_add.length": "길이 (m)", + "soil_add.height": "높이 (m)", + "soil_add.before": "기준측점 전 (m)", + "soil_add.after": "기준측점 후 (m)", + "chute_add": "도수로/돌붙임", + "chute_add.structure": "구조", + "chute_add.structure=도수로": "도수로", + "chute_add.structure=돌붙임": "돌붙임" + } + }, + "box_culvert": { + "동작": [ + "계곡통과시설", + "BOX_본체규격", + "날개벽" + ] + }, + "ford_pavement": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "ford_bridge": { + "동작": [ + "계곡통과시설", + "월류단면", + "날개벽" + ], + "이름": { + "ford.diameter": "관경", + "ford.diameter=800": "800", + "ford.diameter=1000": "1000", + "ford.diameter=1200": "1200", + "ford.diameter=1500": "1500", + "ford.width": "월류 폭 (m)", + "ford.height": "월류 높이 (m)", + "ford.count": "수량 (련)", + "inlet.structure": "구조", + "inlet.structure=없음": "없음", + "inlet.structure=날개벽": "날개벽", + "inlet.structure=흙막이": "흙막이", + "outlet.structure": "구조", + "outlet.structure=없음": "없음", + "outlet.structure=날개벽": "날개벽", + "outlet.structure=흙막이": "흙막이" + } + }, + "revetment": { + "동작": [ + "계곡통과시설", + "기슭막이_한벌" + ] + }, + "pavement_concrete": { + "동작": [ + "포장_물넘이_겹침" + ] + } + }, + "표시": { + "pipe": { + "pipe_kind": false + } + }, + "그룹": { + "pipe": [ + { + "id": "grv6lny", + "이름": "관", + "옵션": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "형상": {} + }, + { + "id": "gnbf9m9", + "이름": "관 보호공(유입)", + "옵션": [ + "inlet_type" + ], + "형상": {} + }, + { + "id": "g9lq9uk", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } + ], + "spoil_bank": [ + { + "id": "gspoildetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "borrow_pit": [ + { + "id": "gborrowdetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/test_52_3_structure_spec_ford_pavement.py b/resources/tester/test_52_3_structure_spec_ford_pavement.py new file mode 100644 index 000000000..39745778a --- /dev/null +++ b/resources/tester/test_52_3_structure_spec_ford_pavement.py @@ -0,0 +1,126 @@ +# -*- coding: utf-8 -*- +"""횡단배수 › 물넘이 포장(표 03) 명세 — PLAN 52-3. + +명세 · 저장 병합은 Node 로 그 자리에서 트랜스파일해 돌림 · 화면은 ORCA `ui_checks/52-3_물넘이포장_컨테이너.js`. +""" + +import json +import os +import re +import subprocess + +import pytest + +from M02_MasterTemplete.M02_Table_Fill_Spec import SPEC_CONTAINERS, container_rows + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(os.path.dirname(HERE)) + +SCRIPT = r""" +const fs = require("fs"), path = require("path"); +const ROOT = process.argv[1]; +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); +const load = (file) => { + const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + new Function("exports", "module", "require", js)(box.exports, box, () => ({})); + return box.exports; +}; +const s = load(path.join(ROOT, "A00_Common/design/design_structure_spec.ts")); +const { FORD_PAVEMENT_SPEC: spec } = load(path.join(ROOT, "A00_Common/design/design_structure_ford_pavement.ts")); +const merge = load(path.join(ROOT, "B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts")); +const old = { ford_width_m: 6, ford_height_m: 0.3, ford_slope_pct: 3, thickness_cm: 20, length_m: 7 }; +const state = s.readState(spec, { ...old, ford_slope_deg: 12.5, ford_chute_1_structure: "돌붙임" }); +const edited = JSON.parse(JSON.stringify(state)); +edited.slope[0].angle = "15"; +edited.chute.push({ structure: "도수로" }); +const written = s.writeOptions(spec, edited); +const prev = { facility: "ford_pavement", options: { ...old, ford_chute_2_structure: "도수로" } }; +const saved = merge.mergeFacilityOptions(prev, { facility: "ford_pavement", options: written }); +process.stdout.write(JSON.stringify({ + containers: spec.containers.map((c) => [c.id, c.title, c.required, c.prefix ?? null, c.link]), + state, written, saved: saved.options, keys: s.specKeys(spec), retired: spec.retired, + links: spec.containers.map((c) => s.linkOf(c, 1)), + formKeys: merge.FORM_OPTION_KEYS.ford_pavement, heads: merge.FORM_OPTION_PREFIXES.ford_pavement, +})); +""" + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", "-e", SCRIPT, ROOT], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_컨테이너_경사각도_필수_도수로돌붙임_선택(out): + assert out["containers"] == [ + ["slope", "경사각도", True, None, "gfordpaveslope"], + ["chute", "도수로/돌붙임", False, "ford_chute", "gfordpavechute"], + ] + assert out["links"] == ["gfordpaveslope", "gfordpavechute1"] + + +def test_저장_값은_도_와_인스턴스_키(out): + assert out["state"] == {"slope": [{"angle": "12.5"}], "chute": [{"structure": "돌붙임"}]} + assert out["written"] == { + "ford_slope_deg": 15, + "ford_chute_1_structure": "돌붙임", + "ford_chute_2_structure": "도수로", + } + + +def test_옛_칸은_지난_정의_값으로_남음(out): + assert out["retired"] == [ + "ford_width_m", + "ford_height_m", + "ford_slope_pct", + "thickness_cm", + "length_m", + ] + # 폼이 모르는 옛 칸은 그대로 · 폼이 아는 머리(빠진 인스턴스)는 폼 값으로 갈아 끼움 + assert out["saved"] == { + "ford_width_m": 6, + "ford_height_m": 0.3, + "ford_slope_pct": 3, + "thickness_cm": 20, + "length_m": 7, + **out["written"], + } + assert out["formKeys"] == out["keys"]["keys"] == ["ford_slope_deg"] + assert out["heads"] == out["keys"]["prefixes"] == ["ford_chute_"] + + +def test_집계표_짝_명세와_같음(): + text = open( + os.path.join(ROOT, "A00_Common/design/design_structure_ford_pavement.ts"), encoding="utf-8" + ).read() + found = re.findall( + r'title: "([^"]+)",\s*required: false,\s*prefix: "(\w+)",\s*link: "(\w+)"', text + ) + assert [(p, link, t) for t, p, link in found] == list(SPEC_CONTAINERS["ford_pavement"]) + item = { + "type_id": "ford_pavement", + "options": {"ford_chute_1_structure": "도수로", "ford_slope_deg": 10}, + "복사본": "pipe@50.0", + } + rows = container_rows(item, {"gfordpavechute1": "SS000001"}) + assert [ + (r["복사본"], r["그룹"], r["summary_item"], r["options"]["structure"]) for r in rows + ] == [("pipe@50.0__gfordpavechute1", "도수로/돌붙임 1", "SS000001", "도수로")] + + +def test_정의_그룹_경사각도(): + with open( + os.path.join(ROOT, "resources/master_template/구조물정의.json"), encoding="utf-8" + ) as f: + groups = json.load(f)["그룹"]["ford_pavement"] + assert [(g["id"], g["이름"]) for g in groups] == [("gfordpaveslope", "경사각도")] diff --git a/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js b/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js new file mode 100644 index 000000000..53c5fc421 --- /dev/null +++ b/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js @@ -0,0 +1,206 @@ +/* 52-3 — B05 · B06 좌측 「구조물 배치」 물넘이 포장 = 사용자 구조물 표 03 명세(경사각도 · 도수로/돌붙임) + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub7_검증) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m523 = "단계";` 를 붙여 + * orca eval --expression "window.__m523='setup'; $(cat 이 파일)" → 관 12+18(258.12)을 옛 값 든 물넘이 포장으로(PUT · 원래 값은 sessionStorage) + * (orca reload) + * orca eval --expression "window.__m523='edit'; $(cat 이 파일)" → 고치고 [저장] + * (orca reload) + * orca eval --expression "window.__m523='verify'; $(cat 이 파일)" → 다시 연 값 · B06 같은 그리기 + * orca eval --expression "window.__m523='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) + * 기대 수치(edit): 컨테이너 = 경사각도(필수) · [+ 도수로/돌붙임] · 옛 칸 안 보임 · 지난 정의 값 5 · + * 구조 선택지 도수로/돌붙임 · 각도 12.5 · 선택 둘 추가(돌붙임 · 도수로) · 잇기 경사각도 · 둘째 → 첫째 [빼기] · + * 남은 하나 = 도수로 · 잇기가 첫째로 당겨짐 · 저장 파일 = 도 · 인스턴스 키 · 옛 값 그대로 + * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) + */ +(async () => { + const MODE = window.__m523 || "edit"; + const AT = 258.12; + const NAME = /^12\+18물넘이포장/; + const OLD = { + ford_width_m: 6, + ford_height_m: 0.3, + ford_slope_pct: 3, + thickness_cm: 20, + length_m: 7, + }; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const KEYS = [ + "chainage_m", + "source", + "facility", + "start_m", + "end_m", + "options", + "summary_item", + "group_items", + ]; + const plain = (list) => + list.map((p) => + Object.fromEntries( + KEYS.filter((k) => p[k] !== undefined && p[k] !== null).map((k) => [k, p[k]]), + ), + ); + const clearDrafts = () => { + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:")) sessionStorage.removeItem(k); + }; + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const boxes = (c) => [...spec().querySelectorAll(`[data-spec-container="${c}"]`)]; + const ctl = (b, c, item) => + b.querySelector(`[data-spec-item="${c}.${item}"]`).querySelector("select, input"); + const set = async (b, c, item, v) => { + const el = ctl(b, c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(250); + }; + const pick = async () => { + const find = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find( + (l) => l.offsetParent !== null && NAME.test(l.textContent.trim()), + ); + await until(() => !!find()); + const li = find(); + if (spec()) return; // 이미 열림(B06 은 늦게 열려 두 번 누르면 닫힘) + (li.querySelector("button") ?? li).click(); + await until(() => !!spec(), 6000); + if (!spec()) (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(500); + }; + const shape = () => + [...spec().querySelectorAll("[data-spec-container]")].map((b) => ({ + c: b.dataset.specContainer, + legend: b.querySelector("legend").firstChild.textContent, + items: [...b.querySelectorAll("[data-spec-item]")].map((n) => [ + n.dataset.specItem, + n.querySelector("select, input").value, + ]), + link: b.querySelector(".b05-structure__summary-name")?.classList.contains("is-assigned"), + })); + const pickLink = async (b) => { + b.querySelector('[data-b05-summary="pick"]').click(); + await until(() => document.querySelector(".ui-item-picker__tree .ui-tree__row")); + for (let k = 0; k < 8; k++) { + const rows = [...document.querySelectorAll(".ui-item-picker__tree .ui-tree__row")]; + const leaf = rows.find((r) => !r.classList.contains("is-branch")); + if (leaf) { + leaf.click(); + break; + } + rows.find((r) => r.querySelector(".ui-tree__caret")?.textContent === "▸")?.click(); + await w(300); + } + await w(300); + [...document.querySelectorAll(".ui-item-picker__actions button")] + .find((x) => x.textContent.includes("적용")) + .click(); + await w(500); + }; + const visibleKeys = () => + [...document.querySelectorAll(".b05-drainage__facility [data-option-keys]")] + .filter((n) => n.offsetParent !== null) + .map((n) => n.dataset.optionKeys); + const staleCount = () => { + const legend = [...document.querySelectorAll("legend")].find( + (l) => l.offsetParent !== null && l.textContent.trim() === "지난 정의 값", + ); + return legend ? legend.parentElement.querySelectorAll("input").length : 0; + }; + const point = async () => + (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points.find( + (p) => Math.abs(p.chainage_m - AT) < 0.05, + ); + const out = { mode: MODE }; + + if (MODE === "setup") { + const before = await fetchApi.fetchDetailPipePoints(projectId); + if (!sessionStorage.getItem("__523_original")) + sessionStorage.setItem("__523_original", JSON.stringify(before.pipe_points)); + const points = plain(before.pipe_points).map((p) => + Math.abs(p.chainage_m - AT) < 0.05 + ? { ...p, facility: "ford_pavement", options: { ...OLD } } + : p, + ); + await fetchApi.saveDetailPipePoints(projectId, points); + clearDrafts(); + out.ready = (await point())?.facility === "ford_pavement"; + } + + if (MODE === "edit") { + await pick(); + out.containers = shape().map((b) => [b.c, b.legend]); + out.addButton = spec().querySelector('[data-spec-add="chute"]')?.textContent; + out.oldFieldsVisible = visibleKeys(); + out.stale = staleCount(); + await set(boxes("slope")[0], "slope", "angle", "12.5"); + spec().querySelector('[data-spec-add="chute"]').click(); + await w(300); + spec().querySelector('[data-spec-add="chute"]').click(); + await w(300); + out.chuteChoices = [...ctl(boxes("chute")[0], "chute", "structure").options] + .filter((o) => o.value) + .map((o) => o.text); + await set(boxes("chute")[0], "chute", "structure", "돌붙임"); + await set(boxes("chute")[1], "chute", "structure", "도수로"); + await pickLink(boxes("slope")[0]); + await pickLink(boxes("chute")[1]); + out.beforeRemove = shape().map((b) => [b.legend, b.items, b.link]); + boxes("chute")[0].querySelector('[data-spec-remove="chute"]').click(); + await w(400); + out.afterRemove = shape().map((b) => [b.legend, b.items, b.link]); + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + await w(1000); + const p = await point(); + out.file = p && { options: p.options, group_items: Object.keys(p.group_items ?? {}).sort() }; + } + + if (MODE === "verify") { + await pick(); + const b05 = shape(); + out.reopened = b05.map((b) => [b.legend, b.items, b.link]); + out.stale = staleCount(); + location.hash = "#/b06-section"; + await until(() => location.hash.includes("b06"), 5000); + await w(6000); + await pick(); + const b06 = shape(); + out.sameDrawing = JSON.stringify(b05) === JSON.stringify(b06); + if (!out.sameDrawing) out.diff = { b05, b06 }; + out.b06Stale = staleCount(); + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__523_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + await fetchApi.saveDetailPipePoints(projectId, plain(original)); + clearDrafts(); + sessionStorage.removeItem("__523_original"); + const now = await fetchApi.fetchDetailPipePoints(projectId); + const key = (list) => + JSON.stringify( + list.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? {}, p.group_items ?? {}]), + ); + out.restored = key(now.pipe_points) === key(original); + } + return JSON.stringify(out); +})(); From f20a70f388d430d89f60af25ecb1cc6de6883465 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 00:10:47 +0900 Subject: [PATCH 20/37] =?UTF-8?q?fix(structure):=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EA=B4=80=20+=20=ED=8F=AC=EC=9E=A5?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=88=84=EB=A5=B8=20=EA=B2=83=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=EA=B3=A0=EB=A6=84(B06=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EC=95=8C=EC=95=BD=20=C2=B7=20=EB=84=98?= =?UTF-8?q?=EA=B9=80=EA=B0=92=20=C2=B7=2052-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 카드 고름이 측점으로 다시 풀어 관이 먼저 열리던 뿌리 — 누른 구조물 id 를 한 번 넘겨 먼저 씀 - B06 종단 알약 = 그 구조물을 폼에 올림 · B05 → B06 넘김값도 같은 길 - 관 측점선 짝 알약 = 관(같은 측점 첫 구조물 아님) - B06 목록 고름이 알약 강조도 맞춤(B05 와 같은 동작) - 확인 = ui_checks/52-15_같은측점_고르기 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/design/design_view_cross.ts | 9 +- .../B05_Profile_UI_Profile_Structures.ts | 5 +- .../B06_Section_UI_Page_Structures_Panel.ts | 22 ++- B06_Section/B06_Section_UI_Section_View.ts | 9 +- .../B06_Section_UI_Section_View_Menu.ts | 2 + .../B06_Section_UI_Section_View_Profile.ts | 12 +- .../B06_Section_UI_Section_View_Types.ts | 2 + .../tester/ui_checks/52-15_같은측점_고르기.js | 158 ++++++++++++++++++ 8 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 resources/tester/ui_checks/52-15_같은측점_고르기.js diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index a2174fee4..3acaa9e8e 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -179,6 +179,7 @@ export async function mountCrossView( focusChainage: (chainageM) => { if (!isWallSelecting()) focusStationAt(chainageM); }, + markStructure: (structureId) => sectionView.selectMark(structureId), applyPipeOptions: (chainageM, patch) => applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch), }); @@ -316,6 +317,7 @@ export async function mountCrossView( moveStructure: (structureId, toChainageM) => { structuresPanel.moveStructureTo(structureId, toChainageM); }, + selectStructure: (structureId) => structuresPanel.selectStructure(structureId), }); // 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리). const pipeOptionsContext: PipeOptionsContext = { @@ -426,7 +428,12 @@ export async function mountCrossView( handoff, sectionDetail, { - focusStation: (stationId) => sectionView.focusStation(stationId), + // 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15). + focusStation: (stationId) => { + structuresPanel.prefer(handoff?.structureId ?? null); + sectionView.focusStation(stationId); + structuresPanel.prefer(null); + }, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role), diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 4eb2380a8..4844b20c7 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -145,9 +145,12 @@ export function structureIdAtStation( if (!stationId.startsWith(prefix)) return null; const station = stations.find((entry) => irregularStationId(entry.id) === stationId); if (!station) return null; - const hit = structures.find( + const near = structures.filter( (item) => Math.abs(structureAnchorM(item) - station.chainage_m) < SAME_CHAINAGE_M, ); + // 관 측점선의 짝은 관 알약(`pipe-*`)이다 — 같은 측점 다른 구조물(포장 등)을 켜면 그 알약을 + // 누를 때 해제로 읽혀 안 골라졌다(52-15). + const hit = near.find((item) => String(item.structure_id ?? "").startsWith("pipe-")) ?? near[0]; return hit?.structure_id ?? null; } diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 41eb873fd..64f2f4592 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -85,6 +85,8 @@ export interface B06StructuresPanelDeps { stationInterval: () => number; /** 목록·폼에서 고른 시설의 측점 카드를 선택·스크롤한다. */ focusChainage: (chainageM: number) => void; + /** 종단 알약 강조를 목록에서 고른 것에 맞춘다(null = 관 · 해제 — 측점선 짝을 따름). */ + markStructure?: (structureId: string | null) => void; /** 목록이 바뀔 때마다 종단 알약 레인에 같은 목록을 넘긴다(2026-09-07 표시 통일). */ onMarks?: (structures: StructureInstance[], types: StructureType[]) => void; /** 바뀐 값을 그 측점 횡단 캐시(culvert 스펙)에 얹고 카드를 다시 그린다 — @@ -116,6 +118,10 @@ export interface B06StructuresPanel { releasePipe: () => void; /** 그 측점에서 폼이 열 항목을 **열지 않고** 고름 값으로 풀어 준다(세션에 적는 데 쓴다). */ selectionAt: (chainageM: number | null) => DesignSelection; + /** 다음 카드 고름이 측점보다 먼저 열 구조물 — 알약 · 넘김값이 누른 것(null = 측점 규칙대로). */ + prefer: (structureId: string | null) => void; + /** 구조물 하나를 폼에 올리고 그 카드로 간다 — 알약을 누른 길(목록 클릭과 같은 흐름). */ + selectStructure: (structureId: string) => void; /** 지금 목록의 구조물 정본(미저장분 포함) — 카드 찾기에 쓴다. */ getStructures: () => StructureInstance[]; /** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */ @@ -148,6 +154,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc ); let markTypes: StructureType[] = []; + /** 다음 카드 고름이 열 구조물 — 같은 측점에 관이 있어도 누른 것이 먼저다(52-15). */ + let preferredId: string | null = null; /** 종단 알약 레인에 올릴 목록 — 구조물 정본 + 관 정본(가상 구조물). */ const pushMarks = (): void => deps.onMarks?.([...structures, ...pipesToStructureMarks(pipeFacilities)], markTypes); @@ -164,7 +172,12 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc deps.onStructuresChanged?.(); }, onSelect: (structure) => { - if (structure) deps.focusChainage(structureAnchorM(structure)); + if (!structure) return; + deps.markStructure?.(structure.structure_id ?? null); + // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. + preferredId = structure.structure_id ?? null; + deps.focusChainage(structureAnchorM(structure)); + preferredId = null; }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. @@ -241,6 +254,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { + deps.markStructure?.(null); // 알약 강조는 관 측점선 짝으로(같은 측점 구조물 강조를 푼다). // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. selectDesign( deps.projectId, @@ -464,9 +478,13 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc const selection = { chainageM, structureId: null }; return { chainageM, structureId: section.resolveAt(selection).structureId }; }, + prefer: (structureId) => { + preferredId = structureId; + }, + selectStructure: (structureId) => section.selectById(structureId), showAtChainage: (chainageM) => { currentChainageM = null; - const selection: DesignSelection = { chainageM, structureId: null }; + const selection: DesignSelection = { chainageM, structureId: preferredId }; // 계곡 통과 시설이 먼저다 — 같은 측점에 구조물 정본 항목이 겹쳐도 횡단도에 // 그려지는 것은 이쪽이다(순서 · 거리는 `resolveSelection` 한 곳). const hit = resolveSelection(selection, pipeFacilities, structures); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index d44411c16..b03fa52ba 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -446,8 +446,15 @@ export function createSectionView( refreshCards, focusStation(stationId) { if (selectedStationId !== stationId) selectStation(stationId, true); - else revealCard(stationId, "smooth"); + else { + revealCard(stationId, "smooth"); + // 같은 카드라도 알약 강조는 다시 맞춘다 — 목록에서 같은 측점 다른 항목을 고른 경우(52-15). + profile.showCard( + currentDetail?.cross_sections.find((entry) => entry.station_id === stationId) ?? null, + ); + } }, + selectMark: (structureId) => profile.selectMark(structureId), setStationSelectListener(listener) { stationSelectListener = listener; }, diff --git a/B06_Section/B06_Section_UI_Section_View_Menu.ts b/B06_Section/B06_Section_UI_Section_View_Menu.ts index f3f41f182..b78b31fc5 100644 --- a/B06_Section/B06_Section_UI_Section_View_Menu.ts +++ b/B06_Section/B06_Section_UI_Section_View_Menu.ts @@ -29,6 +29,8 @@ export interface SectionStructureEdit { movePipe: (fromChainageM: number, toChainageM: number) => void; /** 구조물(관 아님)을 다른 측점으로 옮긴다. */ moveStructure: (structureId: string, toChainageM: number) => void; + /** 구조물(관 아님) 하나를 폼에 올리고 그 카드로 간다 — 같은 측점 관보다 먼저(52-15). */ + selectStructure: (structureId: string) => void; } /** diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 5137d8e45..4a177276d 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -74,6 +74,8 @@ export interface SectionProfile { redraw: () => void; /** 카드를 골랐다 — 종단도 선택 표시가 따라간다. */ showCard: (section: CrossSection | null) => void; + /** 알약 강조를 그 구조물로(null = 풀기). */ + selectMark: (structureId: string | null) => void; } /** 알약 목록 중 관(계곡 통과 시설)만 종단 측점선으로 편다 — 종단 보기와 같은 규칙. */ @@ -189,9 +191,14 @@ export function createSectionProfile( onIrregularSelect: (station) => host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null), // 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙. + // 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15). onStructureSelect: (structureId) => { const hit = marks.find((entry) => entry.structure_id === structureId); - if (hit) host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + if (!hit) return; + const edit = host.edit(); + if (!isPipeMark(hit) && hit.structure_id && edit) + edit.selectStructure(hit.structure_id); + else host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); }, onStructureMarkMove: (structureId, toChainageM) => { const edit = host.edit(); @@ -245,6 +252,9 @@ export function createSectionProfile( redraw() { profile?.redraw(); }, + selectMark(structureId) { + profile?.setSelectedStructure(structureId); + }, showCard(section) { if (!profile) return; if (!section) { diff --git a/B06_Section/B06_Section_UI_Section_View_Types.ts b/B06_Section/B06_Section_UI_Section_View_Types.ts index 8878e2e69..811c93dad 100644 --- a/B06_Section/B06_Section_UI_Section_View_Types.ts +++ b/B06_Section/B06_Section_UI_Section_View_Types.ts @@ -40,6 +40,8 @@ export interface SectionViewController { /** 좌측 구조물 목록에서 고른 측점 카드를 선택하고 화면에 드러낸다 — 재클릭 토글 없음 * (2026-08-29 B05/B06 일원화: 목록 클릭 → 해당 카드 스크롤·강조). */ focusStation: (stationId: string) => void; + /** 종단 알약 강조를 그 구조물로(null = 풀기 — 카드 측점선 짝을 따름). */ + selectMark: (structureId: string | null) => void; /** 카드(측점) 선택이 바뀔 때 알림 — 좌측 「구조물 배치」 폼이 그 측점 구조물을 * 올린다(2026-08-29 일원화). null = 선택 해제. */ setStationSelectListener: (listener: (stationId: string | null) => void) => void; diff --git a/resources/tester/ui_checks/52-15_같은측점_고르기.js b/resources/tester/ui_checks/52-15_같은측점_고르기.js new file mode 100644 index 000000000..0dfeabe36 --- /dev/null +++ b/resources/tester/ui_checks/52-15_같은측점_고르기.js @@ -0,0 +1,158 @@ +/* 52-15 — 같은 측점에 관 + 포장이 함께 있을 때 좌측 목록 · 알약 · 넘김값이 누른 것을 그대로 고르는가 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(랩탑_보조) · 로그인 상태 · 목록에 「22+0 배관 3」 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5215 = "단계";` 를 붙여 + * orca eval --expression "window.__m5215='setup'; $(cat 이 파일)" → sessionStorage 사진 · 22+0 에 콘크리트 포장 [추가](저장 안 함) + * orca eval --expression "window.__m5215='check'; $(cat 이 파일)" → 지금 화면(B05 · B06) 목록 고르기 네 길 + * orca eval --expression "window.__m5215='handoff'; $(cat 이 파일)" → B05 에서 포장 고른 채 B06 으로(넘김값) + * orca eval --expression "window.__m5215='pill'; $(cat 이 파일)" → B06 종단 알약으로 포장 고르기 + * orca eval --expression "window.__m5215='restore'; $(cat 이 파일)" → sessionStorage 를 사진 그대로 · 뒤에 orca reload + * 쓰는 것: sessionStorage 만(서버 저장 없음 · 공유 storage) + * 기대 수치: 네 길 모두 누른 종류(pavement_concrete · pipe) · 포장일 때 고름 칸 sid = 포장 id · + * handoff = pavement_concrete · pill = pavement_concrete · restore 뒤 sessionStorage 같음 + */ +(async () => { + const MODE = window.__m5215 || "check"; + const PIPE = /^22\+0 배관/; + const PAVE = /^21\+15~22\+5 콘크리트 포장/; // 기준 22+0 · 길이 10 m + const OTHER = /^4\+5 배관/; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const sec = () => document.querySelector(".b05-structure-section"); + const spec = () => sec().querySelector(".b05-structure__spec"); + const selects = () => sec().querySelectorAll(".b05-structure__grid select"); + const fire = (el, value) => { + el.value = value; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const specCtl = (item) => + spec()?.querySelector(`[data-spec-item="detail.${item}"]`)?.querySelector("select, input"); + const listItems = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const text = (li) => li.innerText.replace(/\s+/g, " ").trim(); + const row = (re) => listItems().find((li) => re.test(text(li))); + const click = async (re) => { + const li = row(re); + if (!li) return false; + (li.querySelector("button") ?? li).click(); + await w(900); + return true; + }; + const pickKey = () => + Object.keys(sessionStorage).find((k) => k.includes("structure-pick") && k.includes(PROJECT)); + const PROJECT = "936be972-11bc-46c2-8bf3-b15d8de7df0d"; + const picked = () => { + const raw = pickKey() ? sessionStorage.getItem(pickKey()) : null; + let sid = null; + try { + const value = JSON.parse(raw); + sid = (value?.value ?? value)?.sid ?? null; + } catch { + /* 빈 값 */ + } + return { type: selects()[1]?.value ?? null, sid }; + }; + /** 한 화면의 네 길 — 다른 측점 → 포장(첫 눌림) · 다른 측점 → 관 · 관 → 포장 · 포장 → 관. */ + const fourWays = async () => { + const out = {}; + const run = async (name, first, second) => { + await click(first); + await click(second); + out[name] = picked(); + }; + await run("otherToPave", OTHER, PAVE); + await run("otherToPipe", OTHER, PIPE); + await click(PIPE); // 재선택 = 해제 — 관이 골라진 채로 시작 + await click(PIPE); + await click(PAVE); + out.pipeToPave = picked(); + await click(PIPE); + out.paveToPipe = picked(); + await click(PIPE); // 해제 + return out; + }; + const out = { mode: MODE }; + await until(() => listItems().length > 0); + + if (MODE === "setup") { + window.__m5215snap = JSON.stringify(Object.entries(sessionStorage)); + const [g, t] = selects(); + fire(g, "G"); + await w(300); + fire(t, "pavement_concrete"); + await w(1500); + const anchor = () => + sec().querySelectorAll(".b05-structure__position-row > label")[2].querySelectorAll("input"); + fire(anchor()[0], "22"); + await w(300); + fire(anchor()[1], "0"); + await w(500); + out.anchor = [...anchor()].map((i) => i.value); + fire(specCtl("spec"), "콘크리트"); + await w(200); + fire(specCtl("height"), "0.35"); + await w(200); + sec().querySelector(".is-primary").click(); // 추가 + await w(1000); + out.rows = listItems() + .map(text) + .filter((t) => /22\+[05]/.test(t)); + } else if (MODE === "check") { + out[location.hash.includes("b06") ? "b06" : "b05"] = await fourWays(); + } else if (MODE === "handoff") { + await click(OTHER); + await click(PAVE); // B05 에서 포장을 고른 채 옮김 + out.b05Picked = picked(); + location.hash = "#/b06-section"; + await until(() => listItems().length > 0 && selects()[1]?.value, 20000); + await w(2000); + out.handoff = picked(); + out.handoffOk = out.handoff.type === "pavement_concrete"; + } else if (MODE === "pill") { + // 알약 — B06 종단 알약 레인의 포장을 누른다(다른 측점을 고른 뒤 · 같은 측점 관이 골라진 뒤). + await click(OTHER); + await click(PAVE); + const sid = picked().sid; + await click(PAVE); // 해제 + const pill = () => + [...document.querySelectorAll("[data-structure-id]")].find( + (node) => node.dataset.structureId === sid, + ); + out.pillFound = !!pill(); + for (const [name, first] of [ + ["otherToPill", OTHER], + ["pipeToPill", PIPE], + ]) { + await click(first); + pill()?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(900); + out[name] = picked(); + } + await click(PIPE); // 관 고른 채로 끝났으면 해제 + out.pillOk = + out.otherToPill?.type === "pavement_concrete" && out.pipeToPill?.type === "pavement_concrete"; + } else if (MODE === "restore") { + const snap = window.__m5215snap; + if (!snap) { + out.error = "사진 없음"; + } else { + sessionStorage.clear(); + for (const [k, v] of JSON.parse(snap)) sessionStorage.setItem(k, v); + out.same = JSON.stringify(Object.entries(sessionStorage)) === snap; + } + } + const ways = (r) => + !!r && + r.otherToPave.type === "pavement_concrete" && + !!r.otherToPave.sid && + r.otherToPipe.type === "pipe" && + r.pipeToPave.type === "pavement_concrete" && + r.paveToPipe.type === "pipe"; + if (out.b05) out.b05Ok = ways(out.b05); + if (out.b06) out.b06Ok = ways(out.b06); + return JSON.stringify(out); +})(); From bf33a8a9a206e2f3a8c5e05de92695d4f74a1494 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 00:16:37 +0900 Subject: [PATCH 21/37] =?UTF-8?q?test(B05):=20=EC=98=9B=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=99=95=EB=B3=B5=20=EC=8B=9C=ED=97=98=20=E2=80=94=20=EC=88=98?= =?UTF-8?q?=20=C2=B7=20=EC=A2=85=EB=A5=98=20=C2=B7=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20(PLAN=2052-7c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 옛 대피소 · 정차·작업장 · 차돌림곳 · 종단배수 6 · 계류사방 3(기슭막이 = pipe_points 정본 제외) = 12 건 - 저장 → 다시 열기 → 다시 저장 → 다시 열기 = 12 건 · 측점 · 옵션 같음 · 임시 storage - 랩탑_보조 7+5~8+0 대피소 = 52-7 화면 검증 seed 단계가 넣은 시험 건(memo 「52-7 옛 대피소」) · restore 단계가 원래 목록(6 건)으로 되돌림 = 데이터 잃음 아님 · 코드 원인 없음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../tester/test_52_7c_legacy_roundtrip.py | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 resources/tester/test_52_7c_legacy_roundtrip.py diff --git a/resources/tester/test_52_7c_legacy_roundtrip.py b/resources/tester/test_52_7c_legacy_roundtrip.py new file mode 100644 index 000000000..e02ed795b --- /dev/null +++ b/resources/tester/test_52_7c_legacy_roundtrip.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +"""옛 종류 구조물 저장 왕복(PLAN 52-7c) — 저장 · 다시 열기 · 다시 저장 뒤에도 수 · 종류 그대로. + +옛 대피소 · 정차·작업장 · 차돌림곳(52-7 고르기 목록 뺌) · 종단배수 · 계류사방(52-8 숨김) 모두. +임시 storage(tmp_path) · 라우터 PUT/GET. +""" + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import B05_Profile.B05_Profile_Structures_Router as router_module +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + +PROJECT_ID = "52700000-0000-0000-0000-00000000007c" +URL = f"/api/projects/{PROJECT_ID}/route/structures" +LEGACY = ("refuge", "work_yard", "turnaround") + + +def _client(tmp_path, monkeypatch): + async def project_root(project_id): + return str(tmp_path / "project") + + async def invalidate(project_id): + return True + + async def route_length(project_id): + return None + + monkeypatch.setattr(router_module, "_project_root", project_root) + monkeypatch.setattr(router_module, "_invalidate_downstream", invalidate) + monkeypatch.setattr(router_module, "_route_length", route_length) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _record(type_, at): + options = {} + for o in type_.options: + if o.default not in (None, ""): + options[o.key] = o.default + elif o.required: + options[o.key] = o.choices[0] if o.choices else 1 + record = {"type_id": type_.type_id, "placement": type_.placement, "options": options} + if type_.placement == "interval": + record.update(chainage_m=at, start_m=at, end_m=at + 10) + else: + record["chainage_m"] = at + return record + + +def _kinds(body): + return sorted((s["type_id"], s["chainage_m"]) for s in body["structures"]) + + +def test_옛_종류_저장_왕복_수_그대로(tmp_path, monkeypatch): + types = structure_type_map() + ids = [*LEGACY, *(t for t, v in types.items() if v.group in ("B", "D") and not v.managed_by)] + assert len(ids) >= 3 + 6 + 3 # 기슭막이 = pipe_points 정본 + records = [_record(types[t], 20.0 * (i + 1)) for i, t in enumerate(ids)] + client = _client(tmp_path, monkeypatch) + + first = client.put(URL, json={"base_revision": 0, "structures": records}) + assert first.status_code == 200, first.text + opened = client.get(URL).json() + assert len(opened["structures"]) == len(ids) + want = _kinds(opened) + assert sorted(t for t, _ in want) == sorted(ids) + + # 다시 연 목록 그대로 저장 → 다시 열기 + again = client.put( + URL, json={"base_revision": opened["revision"], "structures": opened["structures"]} + ) + assert again.status_code == 200, again.text + reopened = client.get(URL).json() + assert _kinds(reopened) == want + by_id = {s["structure_id"]: s["options"] for s in opened["structures"]} + assert {s["structure_id"]: s["options"] for s in reopened["structures"]} == by_id From 6f1d0508a2e74c92d10c7c640614e3abe1d97e5d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 00:50:55 +0900 Subject: [PATCH 22/37] =?UTF-8?q?feat(design):=20=EA=B3=B5=EC=9A=A9=20?= =?UTF-8?q?=ED=8B=80=20=EC=84=A0=ED=83=9D=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=E3=80=8C=ED=95=98=EB=82=98=EB=A7=8C=E3=80=8D=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C(max)=20=E2=80=94=20=EB=AC=BC=EB=84=98?= =?UTF-8?q?=EC=9D=B4=20=ED=8F=AC=EC=9E=A5=20=EB=8F=84=EC=88=98=EB=A1=9C/?= =?UTF-8?q?=EB=8F=8C=EB=B6=99=EC=9E=84=20=C2=B7=20=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=82=B4=EC=9A=A9(52-17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세 컨테이너 칸 max — 차면 [추가] 숨김 · 빼면 다시 보임 · 옛 저장이 넘쳐도 다 보이고 지우지 않음 - 물넘이 포장 chute · 기타 detail = max 1 · 배관 흙막이 추가 · 도수로/돌붙임은 여러 개 그대로 - 시험: test_52_17_single_container · 헬퍼 max · ui_checks 52-3 · 52-10 을 하나만 흐름으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../design/design_structure_ford_pavement.ts | 1 + A00_Common/design/design_structure_form.ts | 5 +- A00_Common/design/design_structure_road.ts | 1 + A00_Common/design/design_structure_spec.ts | 4 +- resources/tester/helper_52_structure_spec.cjs | 17 ++++++ .../tester/test_52_17_single_container.py | 40 ++++++++++++++ .../tester/ui_checks/52-10_노면공_기타.js | 52 ++++++++++--------- .../ui_checks/52-3_물넘이포장_컨테이너.js | 18 ++++--- 8 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 resources/tester/test_52_17_single_container.py diff --git a/A00_Common/design/design_structure_ford_pavement.ts b/A00_Common/design/design_structure_ford_pavement.ts index f9ca5257c..c69e89719 100644 --- a/A00_Common/design/design_structure_ford_pavement.ts +++ b/A00_Common/design/design_structure_ford_pavement.ts @@ -28,6 +28,7 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = { required: false, prefix: "ford_chute", link: "gfordpavechute", + max: 1, items: [ { id: "structure", diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 31af0f4c0..6e63dbbe9 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -2,7 +2,8 @@ * design_structure_form.ts * 구조물 명세(`design_structure_spec`) 그리기 — B05 · B06 「구조물 배치」 칸이 같은 노드를 씀. * - * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] + * · 필수 컨테이너 = 늘 보임(한 벌) · 선택 컨테이너 = [추가] 로 인스턴스를 늘리고 인스턴스마다 [빼기] · + * `max` 에 차면 [추가] 숨김 * · 항목: 고르기(상위 값에 따른 선택지 · 저장 키) · 숫자(맞물림 = 길이 · 전 · 후) · 글(손 입력) · 자동(읽기만) * · 인스턴스마다 「구조물 조합」 잇기 줄(`buildSummaryRow` · 고르기 모달 = 패널의 그룹 [고르기] 길 그대로) — * 값은 패널 `group_items` 에 `linkOf` 키로 둠(빼면 뒤 인스턴스 잇기를 한 칸씩 당김) @@ -216,7 +217,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { for (const c of spec?.containers ?? []) { const list = (state[c.id] ??= c.required ? [{}] : []); list.forEach((values, index) => boxes.push(instanceBox(c, values, index))); - if (c.required) continue; + if (c.required || (c.max && list.length >= c.max)) continue; const add = document.createElement("button"); add.type = "button"; add.className = "b05-route__irregular-btn b05-structure__spec-add"; diff --git a/A00_Common/design/design_structure_road.ts b/A00_Common/design/design_structure_road.ts index 3568dc85b..a3520dbf2 100644 --- a/A00_Common/design/design_structure_road.ts +++ b/A00_Common/design/design_structure_road.ts @@ -55,6 +55,7 @@ export const ETC_SPEC: StructureSpec = { required: false, prefix: "etc_detail", link: "getcdetail", + max: 1, items: [{ id: "note", label: "비고", kind: "text" }], }, ], diff --git a/A00_Common/design/design_structure_spec.ts b/A00_Common/design/design_structure_spec.ts index 74acbdb46..c5a99a1b5 100644 --- a/A00_Common/design/design_structure_spec.ts +++ b/A00_Common/design/design_structure_spec.ts @@ -3,7 +3,7 @@ * 구조물 명세 공용 틀(PLAN 52) — 화면 요소 없는 순수 규칙. 사용자 구조물 표 한 장 = 명세 한 벌. * * · 종류 = 기본 옵션(기준 측점 No + 거리 · 패널 위치 칸이 받음) + 컨테이너[] + 항목[] - * · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개) + * · 컨테이너 = 제목 · 필수(늘 한 벌) / 선택([추가] · [빼기] · 여러 개 · `max` 면 그 수까지) * · 항목 꼴 = 고르기 · 숫자 · 글(손 입력 · 길이 상한 `TEXT_MAX`) · 자동(계산이 채움 · 저장 안 함) · * 맞물림(길이 = 전 + 후) * · 상위 값에 따른 선택지 · 저장 키 = `by`(같은 컨테이너 안 상위 항목 값 → 갈래) @@ -53,6 +53,8 @@ export interface SpecContainer { required: boolean; /** 선택 컨테이너 저장 키 머리 — `{prefix}_{n}_{항목}`. */ prefix?: string; + /** 선택 컨테이너 개수 상한 — 차면 [추가] 숨김(옛 저장이 넘쳐도 다 보임). 없으면 여러 개. */ + max?: number; /** 구조물 조합 잇기 키 — 필수는 정의 그룹 id 그대로 · 선택은 뒤에 n. */ link: string; items: SpecItem[]; diff --git a/resources/tester/helper_52_structure_spec.cjs b/resources/tester/helper_52_structure_spec.cjs index fcfb65245..e625ac36b 100644 --- a/resources/tester/helper_52_structure_spec.cjs +++ b/resources/tester/helper_52_structure_spec.cjs @@ -83,6 +83,23 @@ if (process.argv[2] === "etc") { process.exit(0); } +// 선택 컨테이너 「하나만」(PLAN 52-17) — 종류.컨테이너 → max · 옛 저장 둘은 다 읽힘 +if (process.argv[2] === "max") { + const max = {}; + for (const [type, spec] of Object.entries(STRUCTURE_SPECS)) + for (const c of spec.containers) if (!c.required) max[`${type}.${c.id}`] = c.max ?? null; + const old = { ford_chute_1_structure: "도수로", ford_chute_2_structure: "돌붙임" }; + const state = s.readState(STRUCTURE_SPECS.ford_pavement, old); + process.stdout.write( + JSON.stringify({ + max, + oldTwo: state.chute.length, + back: s.writeOptions(STRUCTURE_SPECS.ford_pavement, state), + }), + ); + process.exit(0); +} + if (process.argv[2] === "names") { process.stdout.write(JSON.stringify(s.nameTable(PIPE_SPEC))); process.exit(0); diff --git a/resources/tester/test_52_17_single_container.py b/resources/tester/test_52_17_single_container.py new file mode 100644 index 000000000..b66cee344 --- /dev/null +++ b/resources/tester/test_52_17_single_container.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +"""공용 틀 선택 컨테이너 「하나만」(max) — PLAN 52-17. + +순수 규칙은 Node 헬퍼(`helper_52_structure_spec.cjs max`)가 돌림 · +화면([추가] 숨김)은 ORCA `ui_checks/52-3` · `52-10` · `52-1`. +""" + +import json +import os +import subprocess + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +def _run(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_52_structure_spec.cjs"), "max"], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_하나만_컨테이너와_여러개_컨테이너(): + out = _run() + assert out["max"] == { + "pipe.soil_add": None, + "pipe.chute_add": None, + "ford_pavement.chute": 1, + "etc.detail": 1, + } + + +def test_옛_저장_둘은_지우지_않음(): + out = _run() + assert out["oldTwo"] == 2 + assert out["back"] == {"ford_chute_1_structure": "도수로", "ford_chute_2_structure": "돌붙임"} diff --git a/resources/tester/ui_checks/52-10_노면공_기타.js b/resources/tester/ui_checks/52-10_노면공_기타.js index b7d2f5f81..10ca498ab 100644 --- a/resources/tester/ui_checks/52-10_노면공_기타.js +++ b/resources/tester/ui_checks/52-10_노면공_기타.js @@ -11,8 +11,8 @@ * 포장 — containers ["detail"] · 상세 내용 · 포장사양 선택지 ["콘크리트"] · 높이 0.35 · 잇기 지정 · * 종류 단위 잇기 줄 가림 · 옛 상세 칸(두께 · 줄눈 …) 안 보임 · 길이 · 전 · 후 칸 보임 * 기타 — 「상세 종류 이름」 글 칸 = 「52-10 시험 기타」 · 목록 이름도 그 글 · - * 선택 컨테이너 「상세 내용」(52-14) 셋 추가 · 비고 글(길이 상한 100) · 1 · 3 잇기 · 2 빼기 → - * 둘 남음(비고 ["52-14 비고", ""] · 잇기 둘 다 · 3 의 잇기가 2 로 당겨짐) · 종류 단위 잇기 줄 가림 + * 선택 컨테이너 「상세 내용」(52-14) 하나만(52-17) — 추가 → [추가] 없음 · 빼기 → 다시 보임 · + * 다시 추가 · 비고 「52-14 비고」(길이 상한 100) · 잇기 · 하나 남음 · 종류 단위 잇기 줄 가림 * verify — 같은 값 · 같은 모양(B05 = B06) */ (async () => { @@ -161,28 +161,28 @@ input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); await w(200); - // 52-14 — 상세 내용 셋 추가 · 비고 · 1 · 3 잇기 · 2 빼기 - for (let k = 0; k < 3; k++) { - spec().querySelector('[data-spec-add="detail"]').click(); - await w(200); - } + // 52-14 · 52-17 — 상세 내용 하나만: 추가 → [추가] 없음 · 빼기 → 다시 보임 · 다시 추가 · 비고 · 잇기 + const addShown = () => !!spec().querySelector('[data-spec-add="detail"]'); const notes = () => [...spec().querySelectorAll('[data-spec-item="detail.note"] input')]; - for (const [k, text] of [ - [0, "52-14 비고"], - [1, "지울 것"], - ]) { - notes()[k].value = text; - notes()[k].dispatchEvent(new Event("change", { bubbles: true })); + const note = async (text) => { + notes()[0].value = text; + notes()[0].dispatchEvent(new Event("change", { bubbles: true })); await w(200); - } - const pickBtns = () => [ - ...spec().querySelectorAll('[data-spec-link] [data-b05-summary="pick"]'), - ]; - row.picked = await linkFirstLeaf(pickBtns()[0]); - row.picked3 = await linkFirstLeaf(pickBtns()[2]); - row.addedShape = shape(item.typeId); - spec().querySelectorAll('[data-spec-remove="detail"]')[1].click(); + }; + spec().querySelector('[data-spec-add="detail"]').click(); + await w(200); + row.addAfterOne = addShown(); + await note("지울 것"); + spec().querySelector('[data-spec-remove="detail"]').click(); await w(300); + row.addAfterRemove = addShown(); + spec().querySelector('[data-spec-add="detail"]').click(); + await w(200); + await note("52-14 비고"); + row.picked = await linkFirstLeaf( + spec().querySelector('[data-spec-link] [data-b05-summary="pick"]'), + ); + row.addAfterReAdd = addShown(); } Object.assign(row, shape(item.typeId), { phase: "edit" }); sec().querySelector(".is-primary").click(); // 추가 @@ -237,10 +237,12 @@ !!etc && !etc.missing && etc.name === NAME && - JSON.stringify(etc.containers) === '["detail","detail"]' && - JSON.stringify(etc.legend) === '["상세 내용 1","상세 내용 2"]' && - JSON.stringify(etc.notes) === '["52-14 비고",""]' && - JSON.stringify(etc.containerLinks) === "[true,true]" && + JSON.stringify(etc.containers) === '["detail"]' && + JSON.stringify(etc.legend) === '["상세 내용 1"]' && + JSON.stringify(etc.notes) === '["52-14 비고"]' && + JSON.stringify(etc.containerLinks) === "[true]" && + (etc.phase !== "edit" || + (etc.addAfterOne === false && etc.addAfterRemove === true && etc.addAfterReAdd === false)) && etc.noteMax === 100 && !etc.typeSummaryVisible; return JSON.stringify(out); diff --git a/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js b/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js index 53c5fc421..01a7e673e 100644 --- a/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js +++ b/resources/tester/ui_checks/52-3_물넘이포장_컨테이너.js @@ -9,8 +9,8 @@ * orca eval --expression "window.__m523='restore'; $(cat 이 파일)" → 원래 관 지점으로 되돌림(PUT) · 초안 비움 * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) * 기대 수치(edit): 컨테이너 = 경사각도(필수) · [+ 도수로/돌붙임] · 옛 칸 안 보임 · 지난 정의 값 5 · - * 구조 선택지 도수로/돌붙임 · 각도 12.5 · 선택 둘 추가(돌붙임 · 도수로) · 잇기 경사각도 · 둘째 → 첫째 [빼기] · - * 남은 하나 = 도수로 · 잇기가 첫째로 당겨짐 · 저장 파일 = 도 · 인스턴스 키 · 옛 값 그대로 + * 구조 선택지 도수로/돌붙임 · 각도 12.5 · 하나 추가(돌붙임) → [추가] 없음(52-17) · 잇기 경사각도 · 도수로/돌붙임 · + * [빼기] → 잇기 풂 · [추가] 다시 보임 · 다시 추가(도수로) · 저장 파일 = 도 · 인스턴스 키 하나 · 옛 값 그대로 * 기대 수치(verify): 저장 값 그대로 · 같은 그리기(B05 = B06) */ (async () => { @@ -151,21 +151,27 @@ out.oldFieldsVisible = visibleKeys(); out.stale = staleCount(); await set(boxes("slope")[0], "slope", "angle", "12.5"); + // 52-17 — 하나만: 하나 넣으면 [추가] 없음 · 빼면 다시 보임 + const addShown = () => !!spec().querySelector('[data-spec-add="chute"]'); spec().querySelector('[data-spec-add="chute"]').click(); await w(300); - spec().querySelector('[data-spec-add="chute"]').click(); - await w(300); + out.addAfterOne = addShown(); out.chuteChoices = [...ctl(boxes("chute")[0], "chute", "structure").options] .filter((o) => o.value) .map((o) => o.text); await set(boxes("chute")[0], "chute", "structure", "돌붙임"); - await set(boxes("chute")[1], "chute", "structure", "도수로"); await pickLink(boxes("slope")[0]); - await pickLink(boxes("chute")[1]); + await pickLink(boxes("chute")[0]); out.beforeRemove = shape().map((b) => [b.legend, b.items, b.link]); boxes("chute")[0].querySelector('[data-spec-remove="chute"]').click(); await w(400); out.afterRemove = shape().map((b) => [b.legend, b.items, b.link]); + out.addAfterRemove = addShown(); + spec().querySelector('[data-spec-add="chute"]').click(); + await w(300); + await set(boxes("chute")[0], "chute", "structure", "도수로"); + out.final = shape().map((b) => [b.legend, b.items, b.link]); + out.addAfterReAdd = addShown(); document.querySelector('[data-dock-action="save"]').click(); await w(500); out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 60000); From 5a1bcde1dfde2fec465df415ff67ae648cd05578 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 01:09:03 +0900 Subject: [PATCH 23/37] =?UTF-8?q?fix(structure):=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20=EC=83=81=EC=9E=90=20=EA=BC=B4=20=3D=20?= =?UTF-8?q?=EB=AA=85=EC=84=B8=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EA=BC=B4(52-18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잇기 줄 글 「집계 항목」 → 「구조물 조합」 (가드레일 · 맹암거 · 대피로 · 사토장 · 토취장) - 그룹 숫자 칸 = −/+ 단추 달린 숫자 칸(0.1 · 소수 1자리 · 라벨 대상 = 숫자 칸) - 상자 = 명세와 같은 조각(group · grid) · 그룹 전용 상자 꼴 CSS 걷음 - 저장 값 · 키 · 잇기 동작 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../B05_Profile_UI_Structures_Groups.ts | 24 +++++++++---------- ...B05_Profile_UI_Structures_Panel_Options.ts | 11 ++++++++- .../B05_Profile_UI_Style_Structures.css | 12 ---------- 3 files changed, 22 insertions(+), 25 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Structures_Groups.ts b/B05_Profile/B05_Profile_UI_Structures_Groups.ts index 63b1afc19..71bdf7beb 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Groups.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Groups.ts @@ -21,7 +21,8 @@ import { hiddenFormKeys } from "./B05_Profile_UI_Drainage_Facility_Merge"; import { buildSummaryRow, type SummaryRow } from "./B05_Profile_UI_Structures_Summary_Row"; import { isOptionVisible } from "./B05_Profile_UI_Structures_Summary"; import { SHAPE_FIELDS, shapeFields, shapeKey } from "./B05_Profile_UI_Structures_Define"; -import { field, numberInput } from "./B05_Profile_UI_Structures_Fields"; +import { numberInput } from "./B05_Profile_UI_Structures_Fields"; +import { grid, group as boxOf, labeled, stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { GROUP_JOIN, @@ -126,13 +127,11 @@ export function createGroupBoxes(): GroupBoxes { ); for (const group of groups) { const keys = hiddenFormKeys(facility, group.options); - const box = document.createElement("fieldset"); - box.className = "b05-structure__group b05-structure__optgroup"; - box.dataset.b05Group = group.id; - const legend = document.createElement("legend"); - legend.textContent = group.name; - const body = document.createElement("div"); - body.className = "b05-structure__grid"; + // 상자 꼴 = 명세 컨테이너(`design_structure_form`)와 같은 조각 — 제목 · 몸통 · 격자 · 잇기 줄(52-18) + const box = boxOf(group.name); + box.root.classList.add("b05-structure__optgroup"); + box.root.dataset.b05Group = group.id; + const body = grid(); for (const scope of scopes) { const marked = [...scope.querySelectorAll("[data-option-keys]")]; // 지금 숨은 칸(다른 시설 몫 · 안 고른 갈래)은 그 자리에 둠 — 같은 키 칸이 두 벌 옮겨 오지 않게 @@ -180,23 +179,24 @@ export function createGroupBoxes(): GroupBoxes { handlers?.change?.(); }); shapeInputs.set(key, input); - body.append(field(shape.label, input)); + body.append(labeled(shape.label, stepper(input, 0.1))); } if (!body.childElementCount) { const none = document.createElement("p"); none.className = "b05-structure__owner-note"; - none.textContent = "옵션 없음 — 집계 항목만 지정"; + none.textContent = "옵션 없음 — 구조물 조합만 지정"; body.append(none); } const row = buildSummaryRow(); + row.root.querySelector(".b05-structure__summary-label")!.textContent = "구조물 조합"; row.set(items[group.id] ?? null); row.bind({ pick: () => handlers?.pick(group.id), clear: () => handlers?.clear(group.id), }); rows.set(group.id, row); - box.append(legend, body, row.root); - boxes.push(box); + box.body.append(body, row.root); + boxes.push(box.root); bodies.push(body); } const stale = diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 650aef741..320f151ef 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -20,6 +20,7 @@ import { staleValues, } from "./B05_Profile_UI_Structures_Define"; import { FORM_OPTION_KEYS } from "./B05_Profile_UI_Drainage_Facility_Merge"; +import { stepper } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; import { paintStructureSpec, specOwnedKeys } from "./B05_Profile_UI_Structures_Spec"; import { solveSplit, specKeys, type SplitRole } from "../A00_Common/design/design_structure_spec"; @@ -97,7 +98,15 @@ export function renderOptionFields( const name = (c: string): string => type.spec_names?.[`${option.key}=${c}`] ?? c; const input = optionControl(option, values[option.key], name); const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`; - const wrap = field(label, input, option.default_basis); + // 정의 그룹 상자로 옮겨 갈 숫자 칸 = 명세 컨테이너와 같은 [-][숫자][+](52-18) + const grouped = + option.input === "number" && !!type.groups?.some((g) => g.options.includes(option.key)); + const wrap = field( + label, + grouped ? stepper(input as HTMLInputElement, 0.1) : input, + option.default_basis, + ); + if (grouped) wrap.htmlFor = input.id; // 라벨 대상 = 숫자 칸(첫 버튼으로 새지 않게) wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) // 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로 const place = gridPlace(type.spots?.[option.key], columns); diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 0d374f4dc..ac4c69368 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -499,25 +499,13 @@ } .b05-structure__optgroup { - display: flex; - flex-direction: column; - gap: 6px; min-width: 0; /* 긴 집계 항목 이름이 상자를 패널 밖으로 밀지 않게(fieldset 최소 폭 · 52-9) */ } -.b05-structure__optgroup > legend { - font-weight: 600; - opacity: 1; -} - .b05-structure__optgroup .is-option-hidden { display: none; } -.b05-structure__optgroup > .b05-structure__grid.is-locked { - opacity: 0.5; -} - /* 구조물 정의 규칙(PLAN 48-4)이 가린 칸 — 그룹 상자로 옮겨 가도 같은 표식 */ .b05-structure-section .is-rule-hidden { display: none; From e32229feff05f22e85709b93082b460941aba2b5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 01:27:57 +0900 Subject: [PATCH 24/37] =?UTF-8?q?test(52-13):=20=EB=AA=85=EC=84=B8=20?= =?UTF-8?q?=ED=8F=BC=20=EC=98=88=EC=99=B8=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=B6=94=EA=B0=80=20=C2=B7=2052-11=20=C2=B7=2052-1?= =?UTF-8?q?5=20=EB=82=A1=EC=9D=80=20=EA=B8=B0=EC=A4=80=20=ED=95=9C=20?= =?UTF-8?q?=EC=A4=84=EC=94=A9=20=EA=B3=A0=EC=B9=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 52-13_예외: 빈 값 · 오가기 · 종류 바꾸기 · 여러 개 컨테이너 - 52-11: 보이는 명세 폼을 집도록 - 52-15: 현재 검증 프로젝트 id 를 읽도록 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01MgChuAcyBqUkhHMTXomQC7 --- .../tester/ui_checks/52-11_정의_이름고치기.js | 2 +- resources/tester/ui_checks/52-13_예외.js | 189 ++++++++++++++++++ .../tester/ui_checks/52-15_같은측점_고르기.js | 2 +- 3 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 resources/tester/ui_checks/52-13_예외.js diff --git a/resources/tester/ui_checks/52-11_정의_이름고치기.js b/resources/tester/ui_checks/52-11_정의_이름고치기.js index 8cffe5b2b..125dd3622 100644 --- a/resources/tester/ui_checks/52-11_정의_이름고치기.js +++ b/resources/tester/ui_checks/52-11_정의_이름고치기.js @@ -214,7 +214,7 @@ const li = $$(".b05-route__irregular-list li").find((l) => /배관 1$/.test(l.textContent.trim()), ); - const spec = () => $(".b05-structure__spec", sec); + const spec = () => $$(".b05-structure__spec", sec).find((n) => n.offsetParent !== null); // 명세 폼이 종류마다 여럿 — 보이는 것 (li.querySelector("button") ?? li).click(); await W(800); if (!(spec() && spec().offsetParent !== null)) (li.querySelector("button") ?? li).click(); diff --git a/resources/tester/ui_checks/52-13_예외.js b/resources/tester/ui_checks/52-13_예외.js new file mode 100644 index 000000000..f3c60e262 --- /dev/null +++ b/resources/tester/ui_checks/52-13_예외.js @@ -0,0 +1,189 @@ +/* 52-13 — 명세 폼 예외 모음(배관 1 · BOX암거 · 세월교 1) — 빈 값 · 오가기 · 종류 바꾸기 · 여러 개 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「배관 1」 「세월교 1」 · 로그인 상태 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5213 = "단계";` 를 붙여 + * cache → 저장 안 하고 B05 → B06 → B05 오가기(캐시 값 그대로) · 종류 바꾸기(배관 → BOX → 배관) + * pipe → 배관 길이 · 전 · 후 비움 + 흙막이 추가 셋 + 도수로/돌붙임 하나 → [저장] + * (orca reload) pipe_v → 다시 열기 · 흙막이 수 3 · 비운 칸 그대로 + * bf → 258.12 를 BOX암거로(PUT) · 가로 비움 · 세월교 1 월류 폭 비움 → [저장] + * (orca reload) bf_v → 다시 열기 · 오류 없음 + * restore → 원래 관 지점으로 되돌림(PUT) · 초안 비움 + * 쓰는 것: 검증 프로젝트 pipe_points.json(되돌림 단계가 원래 값으로 다시 씀) + * 기대 수치: errors 0 · cache.same true · kind.leak 0 · soilCount 3 · inletBlank 빈 칸 셋 · restored true + * (배관 도수로/돌붙임 [추가] 는 하나 뒤에도 보임 — 「하나만」은 물넘이 포장 · 기타만 · 52-3 · 52-10) + */ +(async () => { + const MODE = window.__m5213 || "cache"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const errors = []; + const onErr = (e) => errors.push(String(e.message ?? e.reason ?? e)); + window.addEventListener("error", onErr); + window.addEventListener("unhandledrejection", onErr); + const projectId = localStorage.getItem("frd_current_project_id"); + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const fetchApi = await import(modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c, i = 0) => spec()?.querySelectorAll(`[data-spec-container="${c}"]`)[i]; + const ctl = (c, item, i = 0) => + box(c, i)?.querySelector(`[data-spec-item="${c}.${item}"]`)?.querySelector("select, input"); + const set = async (c, item, v, i = 0) => { + const el = ctl(c, item, i); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + }; + const clearDrafts = () => { + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:pipes:")) sessionStorage.removeItem(k); + }; + const pick = async (re) => { + const find = () => + [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + re.test(l.textContent.trim()), + ); + await until(() => !!find()); + const li = find(); + (li.querySelector("button") ?? li).click(); + await until(() => !!spec(), 6000); + if (!spec()) (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(500); + }; + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + return until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + }; + const items = () => [...spec().querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem); + const point = async (lo, hi) => + (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points.find( + (p) => p.chainage_m > lo && p.chainage_m < hi, + ); + const out = { mode: MODE }; + + if (MODE === "cache") { + await pick(/배관 1$/); + await set("inlet", "structure", "기슭막이"); + await set("inlet", "length", "12"); + out.before = ctl("inlet", "length").value; + location.hash = "#/b06-section"; + await w(7000); + location.hash = "#/b05-profile"; + await w(7000); + await pick(/배관 1$/); + out.after = ctl("inlet", "length")?.value; + out.same = out.before === out.after; + clearDrafts(); + } + + if (MODE === "pipe") { + sessionStorage.setItem("__5213_original", JSON.stringify((await fetchApi.fetchDetailPipePoints(projectId)).pipe_points)); + await pick(/배관 1$/); + await set("inlet", "structure", "기슭막이"); + await set("inlet", "form", "돌망태"); + await set("inlet", "length", ""); + await set("inlet", "before", ""); + await set("inlet", "after", ""); + for (let i = 0; i < 3; i++) { + spec().querySelector('[data-spec-add="soil_add"]').click(); + await w(200); + } + for (let i = 0; i < 3; i++) { + await set("soil_add", "structure", "기슭막이", i); + await set("soil_add", "form", "콘크리트", i); + } + spec().querySelector('[data-spec-add="chute_add"]').click(); + await w(200); + await set("chute_add", "structure", "돌붙임"); + out.soilCount = spec().querySelectorAll('[data-spec-container="soil_add"]').length; + out.saved = await save(); + const p = await point(80, 90); + out.file = p && p.options; + } + + if (MODE === "pipe_v") { + await pick(/배관 1$/); + out.soilCount = spec().querySelectorAll('[data-spec-container="soil_add"]').length; + out.chuteAdd = !!spec().querySelector('[data-spec-add="chute_add"]'); + out.inletBlank = ["length", "before", "after"].map((k) => ctl("inlet", k)?.value); + } + + if (MODE === "bf") { + const orig = (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points; + if (!sessionStorage.getItem("__5213_original")) + sessionStorage.setItem("__5213_original", JSON.stringify(orig)); + const pts = JSON.parse(JSON.stringify(orig)).map((p) => + Math.abs(p.chainage_m - 258.12) < 0.05 + ? { ...p, facility: "box_culvert", options: { body_width_m: 3, body_height_m: 2.5 } } + : p, + ); + await fetchApi.saveDetailPipePoints(projectId, pts); + clearDrafts(); + out.ready = true; + } + + if (MODE === "bf_edit") { + await pick(/BOX암거$/); + await set("body", "size", "사용자 입력"); + await set("body", "width", ""); + out.boxSaved = await save(); + out.boxFile = (await point(250, 260))?.options; + await pick(/세월교 1$/); + await set("ford", "width", ""); + out.fordSaved = await save(); + out.fordFile = (await point(170, 176))?.options; + } + + if (MODE === "bf_v") { + await pick(/BOX암거$/); + out.box = { size: ctl("body", "size")?.value, width: ctl("body", "width")?.value }; + await pick(/세월교 1$/); + out.ford = { width: ctl("ford", "width")?.value, height: ctl("ford", "height")?.value }; + } + + if (MODE === "kind") { + const sec = document.querySelector(".b05-structure-section"); + const sel = () => sec.querySelectorAll(".b05-structure__grid select"); + const fire = (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const seen = {}; + for (const t of ["pipe", "box_culvert", "pipe"]) { + fire(sel()[0], "A"); + await w(300); + fire(sel()[1], t); + await w(600); + seen[t + "#" + Object.keys(seen).length] = items().join(","); + } + const vals = Object.values(seen); + out.seen = vals; + out.leak = vals[0] === vals[2] && !/body\./.test(vals[0]) && !/pipe\./.test(vals[1]) ? 0 : 1; + } + + if (MODE === "restore") { + const original = JSON.parse(sessionStorage.getItem("__5213_original") || "null"); + if (!original) return JSON.stringify({ error: "원래 값 없음" }); + await fetchApi.saveDetailPipePoints(projectId, original); + clearDrafts(); + sessionStorage.removeItem("__5213_original"); + const now = (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points; + out.restored = JSON.stringify(now.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])) === + JSON.stringify(original.map((p) => [p.chainage_m, p.facility ?? null, p.options ?? null])); + } + + out.errors = errors; + return JSON.stringify(out); +})(); diff --git a/resources/tester/ui_checks/52-15_같은측점_고르기.js b/resources/tester/ui_checks/52-15_같은측점_고르기.js index 0dfeabe36..605392815 100644 --- a/resources/tester/ui_checks/52-15_같은측점_고르기.js +++ b/resources/tester/ui_checks/52-15_같은측점_고르기.js @@ -44,7 +44,7 @@ }; const pickKey = () => Object.keys(sessionStorage).find((k) => k.includes("structure-pick") && k.includes(PROJECT)); - const PROJECT = "936be972-11bc-46c2-8bf3-b15d8de7df0d"; + const PROJECT = localStorage.getItem("frd_current_project_id"); // 환경마다 검증 프로젝트가 다름 const picked = () => { const raw = pickKey() ? sessionStorage.getItem(pickKey()) : null; let sid = null; From 406efc7192a91692db129f7a2b88266939e8ad41 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 01:38:21 +0900 Subject: [PATCH 25/37] =?UTF-8?q?fix(structure):=20B05=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=ED=8F=BC=EC=9D=B4=20=EA=BA=BC=EC=A7=84=20?= =?UTF-8?q?=EC=A2=85=EB=A5=98(B=EA=B5=B0=20=EC=B8=A1=EA=B5=AC=20=EB=93=B1)?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=EC=9D=84=20=EC=A0=95=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=EC=9C=BC=EB=A1=9C=20=ED=92=82(52-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05 패널에 종류 전부를 넘기고 고르기 목록에서만 꺼진 종류 · 고를 종류 없는 군을 가림 · B06 그대로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s --- B05_Profile/B05_Profile_UI_Page_Structures.ts | 9 +++--- .../B05_Profile_UI_Structures_Panel.ts | 18 +++++------ .../B05_Profile_UI_Structures_Panel_Types.ts | 30 ++++++++++++++----- 3 files changed, 35 insertions(+), 22 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Page_Structures.ts b/B05_Profile/B05_Profile_UI_Page_Structures.ts index 49cc98731..ef571f0bc 100644 --- a/B05_Profile/B05_Profile_UI_Page_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Page_Structures.ts @@ -313,15 +313,14 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) { async function loadStructures(): Promise { try { // 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07). - // · 추가 메뉴 — 켜진 종류만(B05 는 종전대로 여섯 군). - // · 알약 레인·군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 레지스트리에서 - // 꺼져 있는데, 켜진 것만 주면 이름·색·군을 못 찾아 종단에 「?」 회색 알약으로 뜬다. + // · 고르기 목록 — 켜진 종류만(B05 는 종전대로 여섯 군). + // · 목록 이름 · 폼 · 알약 레인 · 군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 + // 레지스트리에서 꺼져 있는데, 켜진 것만 주면 이름을 못 찾아 영문 키 · 「?」 알약으로 뜬다. const [allTypes, stored] = await Promise.all([ fetchStructureTypes(true), fetchStructures(deps.projectId), ]); - const menuTypes = allTypes.filter((type) => type.enabled); - deps.panel().structures.setTypes(menuTypes); + deps.panel().structures.setTypes(allTypes, (type) => type.enabled); deps.profilePanel().setStructureTypes(allTypes); // A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다. structureTypeMap = new Map( diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 2a4adbb4f..5eec636e4 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -53,9 +53,9 @@ export { GROUP_LABELS } from "./B05_Profile_UI_Structures_Panel_Types"; export type { PipeFacilityItem, StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types"; import { DEFAULT_INTERVAL_LENGTH_M, - GROUP_LABELS, - groupOption, + groupOptions, hasComputedRange, + pickOption, readBeforeOption, readLengthOption, type PipeFacilityItem, @@ -165,6 +165,7 @@ export function createStructuresSection( } let types: StructureType[] = []; + let canPick: (type: StructureType) => boolean = () => true; // 배열 자체를 바꾸지 않는다(조각들이 참조로 들고 있음) — 내용만 갈아끼운다. const structures: StructureInstance[] = []; let pipeFacilities: PipeFacilityItem[] = []; @@ -348,7 +349,7 @@ export function createStructuresSection( const candidates = group ? types.filter((t) => t.group === group && pickable(t)) : []; typeSelect.replaceChildren( new Option("— 선택 —", ""), - ...candidates.map((type) => new Option(type.name, type.type_id)), + ...candidates.map((type) => pickOption(type.name, type.type_id, !canPick(type))), ); typeSelect.value = keepTypeId && candidates.some((type) => type.type_id === keepTypeId) ? keepTypeId : ""; @@ -539,14 +540,11 @@ export function createStructuresSection( facility: facilityOptions, listRoot: list, hasSelection: () => editingId !== null || selectedPipeChainage !== null, - setTypes(next) { + setTypes(next, pick = () => true) { types = next; - const groups = new Map(next.map((t) => [t.group, t.group_name] as const)); - // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. 이름은 정의(48) 먼저. - groupSelect.replaceChildren( - new Option("— 선택 —", ""), - ...[...groups].map(([g, name]) => groupOption(g, name ?? GROUP_LABELS[g] ?? g)), - ); + canPick = pick; + // 첫 항목은 빈칸 — 진입·리셋 상태에서 군·종류를 강제로 고르게 두지 않는다. + groupSelect.replaceChildren(new Option("— 선택 —", ""), ...groupOptions(next, pick)); groupSelect.value = ""; syncTypeOptions(); }, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts index 04fa037cd..86e12199c 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Types.ts @@ -38,14 +38,29 @@ export const GROUP_LABELS: Record = { * 옛 값 · 도면 · 집계는 그대로이고 데이터도 지우지 않는다. */ export const HIDDEN_PICK_GROUPS: ReadonlySet = new Set(["B", "D"]); -/** 군 드롭다운 항목 — 뺄 군은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */ -export function groupOption(group: string, name: string): HTMLOptionElement { - const option = new Option(name, group); - option.hidden = HIDDEN_PICK_GROUPS.has(group); - if (option.hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게 +/** 고르기 목록 항목 — 가릴 항목은 가림(옛 항목을 열 때는 값으로 그대로 고를 수 있음). */ +export function pickOption(text: string, value: string, hidden: boolean): HTMLOptionElement { + const option = new Option(text, value); + option.hidden = hidden; + if (hidden) option.dataset.pickHidden = ""; // 정의 폼의 「가림 풀기」가 건드리지 않게 return option; } +/** 군 드롭다운 항목들 — 이름은 정의(48) 먼저 · 뺄 군과 고를 종류가 없는 군은 가림. */ +export function groupOptions( + types: StructureType[], + pick: (type: StructureType) => boolean, +): HTMLOptionElement[] { + const groups = new Map(types.map((t) => [t.group, t.group_name] as const)); + return [...groups].map(([g, name]) => + pickOption( + name ?? GROUP_LABELS[g] ?? g, + g, + HIDDEN_PICK_GROUPS.has(g) || !types.some((t) => t.group === g && pick(t)), + ), + ); +} + /** 구간형 신규 추가 시 기본 구간 길이(m). 사용자가 종점을 바로 고칠 수 있게 짧게 잡는다. */ export const DEFAULT_INTERVAL_LENGTH_M = 15; @@ -95,8 +110,9 @@ export interface StructuresSection { /** 배치된 구조물 목록(
    ) — 섹션 본문이 아니라 사이드 하단 고정 영역에 붙인다 * (2026-08-18 사용자 지시: 폼 길이에 밀려 목록이 화면 밖으로 나가던 문제). */ listRoot: HTMLElement; - /** 서버에서 받은 타입 목록을 채운다(최초 1회). */ - setTypes: (types: StructureType[]) => void; + /** 서버에서 받은 타입 목록을 채운다(최초 1회). 이름·폼은 넘긴 종류 전부로 풀고, + * 고르기 목록엔 `pick` 이 고른 종류만 보임(빼면 전부). */ + setTypes: (types: StructureType[], pick?: (type: StructureType) => boolean) => void; /** 서버 정본으로 목록을 교체한다(진입·복원·저장 후). */ setStructures: (structures: StructureInstance[]) => void; getStructures: () => StructureInstance[]; From 1a9055444f56e0fd27dfb62cb4d24c61d7515056 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 01:43:24 +0900 Subject: [PATCH 26/37] =?UTF-8?q?fix(box):=20=EB=B3=B8=EC=B2=B4=20?= =?UTF-8?q?=E3=80=8C=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=85=EB=A0=A5?= =?UTF-8?q?=E3=80=8D=20=EB=B9=88=20=EC=B9=B8=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=92=A4=20=EB=8B=A4=EC=8B=9C=20=EC=97=B4=EC=96=B4=EB=8F=84=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9E=85=EB=A0=A5=20=C2=B7=20B06?= =?UTF-8?q?=20=EB=8B=A4=EB=A6=AC=EA=B0=80=20=EC=9C=A0=EC=9E=85=C2=B7?= =?UTF-8?q?=EC=9C=A0=EC=B6=9C=20=EA=B5=AC=EC=A1=B0=20=EA=B8=80=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80(52-19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 규격 판정(bodyOf): 가로 · 세로 중 손 값 하나라도 있으면 사용자 입력 + 빈 칸은 빈 채 - 둘 다 비면 표시 키 body_custom 만 저장 · 폼 칸 목록에 넣어 숫자 채우면 지워짐 - B06 폼→조정창 다리: 흙막이 · 날개벽 글을 있음/없음으로 바꿔 적던 것 → 글 그대로 - 시험: 52-2 헬퍼 한 칸 · 두 칸 빈 경우 · ui_checks 52-19 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/design/design_structure_box.ts | 23 +++-- .../B05_Profile_UI_Drainage_Facility_Merge.ts | 2 +- .../B06_Section_UI_Page_Ford_Controls.ts | 10 ++- .../tester/helper_52_structure_spec_box.cjs | 8 ++ .../tester/test_52_structure_spec_box.py | 18 ++++ resources/tester/ui_checks/52-19_박스_빈칸.js | 85 +++++++++++++++++++ 6 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 resources/tester/ui_checks/52-19_박스_빈칸.js diff --git a/A00_Common/design/design_structure_box.ts b/A00_Common/design/design_structure_box.ts index 639a01159..c76585021 100644 --- a/A00_Common/design/design_structure_box.ts +++ b/A00_Common/design/design_structure_box.ts @@ -5,6 +5,7 @@ * 컨테이너 셋(모두 필수): 본체 · 유입 구조 · 유출 구조. 구조 · 정본 = 이 명세 · 이름만 정의(M02)에서 바꿈. * 저장 키 = pipe_points.json 옛 키 그대로(B06 횡단 · B07 · B08 이 읽는 자리): * · 본체 = `body_width_m` · `body_height_m` 수 — 규격 칸은 저장 안 함(폭 · 높이로 되찾음 · `bodyOf`) + * · 사용자 입력에 두 칸 다 비면 `body_custom` = 「사용자 입력」 표시만 * · 유입 · 유출 구조 = `wing_in` · `wing_out`(없음 · 날개벽 · 흙막이 · 옛 「있음」 = 날개벽) * — 엔진은 날개벽만 세움(`_wing_spec` ↔ `wingSpec` 짝) * 표 02 에 없는 옛 날개벽 치수 칸은 화면에서 빼고 값은 남김(지난 정의 값). @@ -14,6 +15,8 @@ import type { Instance, SpecItem, StructureSpec } from "./design_structure_spec"; export const BOX_CUSTOM = "사용자 입력"; +/** 사용자 입력에 가로 · 세로 둘 다 빈 채 저장했다는 표시(폭 · 높이 수가 없을 때만 적음). */ +export const BOX_CUSTOM_KEY = "body_custom"; /** 본체 규격 → [가로, 세로](m) — 실무 관측 규격(2026-08-17 사용자 확정). */ const BOX_PRESETS: Record = { "2.0×2.0": [2, 2], @@ -97,21 +100,25 @@ export function boxOptionsIn( return out; } -/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 아니면 사용자 입력 · 없으면 빈 값. */ +/** 저장 폭 · 높이 → 본체 칸 값 — 규격과 맞으면 그 규격 · 손 값이 하나라도 있거나 사용자 입력 표시면 + * 사용자 입력(빈 칸은 빈 채) · 아니면 빈 값. */ export function bodyOf(options: Record): Instance { const width = Number(options.body_width_m); const height = Number(options.body_height_m); - if (!(width > 0 && height > 0)) return { size: "" }; const preset = Object.entries(BOX_PRESETS).find( ([, [w, h]]) => Math.abs(w - width) < 0.05 && Math.abs(h - height) < 0.05, ); - return preset - ? { size: preset[0] } - : { size: BOX_CUSTOM, width: String(width), height: String(height) }; + if (preset) return { size: preset[0] }; + if (!(width > 0) && !(height > 0) && options[BOX_CUSTOM_KEY] !== BOX_CUSTOM) return { size: "" }; + const side = (n: number): string => (n > 0 ? String(n) : ""); + return { size: BOX_CUSTOM, width: side(width), height: side(height) }; } -/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음). */ -export function bodyOptions(body: Instance | undefined): Record { +/** 본체 칸 값 → 저장 폭 · 높이 — 규격이면 규격 수(사용자 입력은 명세 키가 적음) · 사용자 입력에 두 칸 다 + * 비면 표시 키만(다시 열 때 「사용자 입력」). */ +export function bodyOptions(body: Instance | undefined): Record { const preset = BOX_PRESETS[body?.size ?? ""]; - return preset ? { body_width_m: preset[0], body_height_m: preset[1] } : {}; + if (preset) return { body_width_m: preset[0], body_height_m: preset[1] }; + const blank = !(body?.width ?? "").trim() && !(body?.height ?? "").trim(); + return body?.size === BOX_CUSTOM && blank ? { [BOX_CUSTOM_KEY]: BOX_CUSTOM } : {}; } diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts index 50c414500..b2bdccdd4 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Merge.ts @@ -35,7 +35,7 @@ export const FORM_OPTION_KEYS: Readonly> = { ...revetKeys("outlet"), ], // BOX = 표 02 명세 키(PLAN 52-2) — 옛 날개벽 치수는 폼에서 빠져 이어 붙임(지난 정의 값) - box_culvert: ["body_width_m", "body_height_m", "wing_in", "wing_out"], + box_culvert: ["body_width_m", "body_height_m", "body_custom", "wing_in", "wing_out"], // 물넘이 포장 = 표 03 명세 키(PLAN 52-3 · `design_structure_ford_pavement`) — 옛 월류 · 경사 · 두께 · 길이는 // 폼에서 빠져 이어 붙임 · 도수로/돌붙임 키는 `FORM_OPTION_PREFIXES` ford_pavement: ["ford_slope_deg"], diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index bec9877ef..a94fd89b5 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -25,6 +25,8 @@ import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; /** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ type WingPatch = Partial<{ installed: boolean; + /** 폼이 낸 설치 글(날개벽 · 흙막이 …) — 있으면 저장에 그 글 그대로(있음/없음으로 안 바꿈). */ + text: string; height_m: number; length_m: number; angle_deg: number; @@ -34,7 +36,8 @@ type WingPatch = Partial<{ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record { const prefix = role === "inlet" ? "wing_in" : "wing_out"; const options: Record = {}; - if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음"; + if (patch.installed !== undefined) + options[prefix] = patch.text ?? (patch.installed ? "있음" : "없음"); if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m; if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m; if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg; @@ -82,7 +85,10 @@ function wingPatchFrom(patch: Record, prefix: string): }; const wing: WingPatch = {}; // 설치 값 글(있음 · 없음 · BOX 날개벽 · 흙막이) — 백엔드 `_wing_spec` 과 같은 판정. - if (patch[prefix] !== undefined) wing.installed = wingInstalled(patch[prefix]); + if (patch[prefix] !== undefined) { + wing.installed = wingInstalled(patch[prefix]); + wing.text = String(patch[prefix]); + } const heightM = num(`${prefix}_height_m`); if (heightM !== undefined) wing.height_m = heightM; const lengthM = num(`${prefix}_length_m`); diff --git a/resources/tester/helper_52_structure_spec_box.cjs b/resources/tester/helper_52_structure_spec_box.cjs index e86007804..ac77f9982 100644 --- a/resources/tester/helper_52_structure_spec_box.cjs +++ b/resources/tester/helper_52_structure_spec_box.cjs @@ -55,7 +55,15 @@ const out = { st.body[0].height = "1.8"; }), blank: roundTrip({}), + // 52-19 — 사용자 입력에서 한 칸 · 두 칸 비우고 저장 → 다시 열기 + oneBlank: roundTrip({ body_height_m: 2.5 }), + bothBlank: roundTrip({ body_width_m: 2, body_height_m: 2 }, (st) => { + st.body[0].size = "사용자 입력"; + st.body[0].width = ""; + st.body[0].height = ""; + }), keys: s.specKeys(SPEC), retired: SPEC.retired, }; +out.bothBlankAgain = roundTrip(out.bothBlank.out); process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_52_structure_spec_box.py b/resources/tester/test_52_structure_spec_box.py index 95a9307a9..2091a01a1 100644 --- a/resources/tester/test_52_structure_spec_box.py +++ b/resources/tester/test_52_structure_spec_box.py @@ -76,6 +76,24 @@ def test_빈_값은_빈_채로(out): assert out["blank"]["state"]["body"][0]["size"] == "" +def test_사용자_입력_빈_칸은_다시_열어도_사용자_입력(out): + # 52-19 — 가로만 비움: 세로 손 값이 남아 사용자 입력 + 빈 가로 + assert out["oneBlank"]["state"]["body"][0] == { + "size": "사용자 입력", + "width": "", + "height": "2.5", + } + assert out["oneBlank"]["out"] == {"body_height_m": 2.5} + # 둘 다 비움: 표시 키만 적고 다시 열면 사용자 입력 + 빈 칸 둘 + assert out["bothBlank"]["out"] == {"body_custom": "사용자 입력"} + assert out["bothBlankAgain"]["state"]["body"][0] == { + "size": "사용자 입력", + "width": "", + "height": "", + } + assert out["bothBlankAgain"]["out"] == {"body_custom": "사용자 입력"} + + def test_정의_파일에_이름_표_그룹_선택지가_올라_있음(): names = _node("names") with open( diff --git a/resources/tester/ui_checks/52-19_박스_빈칸.js b/resources/tester/ui_checks/52-19_박스_빈칸.js new file mode 100644 index 000000000..4048f6acb --- /dev/null +++ b/resources/tester/ui_checks/52-19_박스_빈칸.js @@ -0,0 +1,85 @@ +/* 52-19 — BOX암거 본체 「사용자 입력」 빈 칸 저장 → 다시 열기 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트 · 목록에 「BOX암거」(52-2 edit 뒤 2.4 × 1.8 · 흙막이) + * 부르는 법: `window.__m5219 = "단계";` 를 붙여 — 단계 사이 orca reload + * one → 가로 비우고 [저장] (reload) view → 사용자 입력 · 가로 빈 칸 · 세로 1.8 + * both → 세로도 비우고 [저장] (reload) view → 사용자 입력 · 빈 칸 둘 · 파일 body_custom + * fill → 2.2 × 1.6 [저장] → 파일에 body_custom 없음 + * 기대: 유입 · 유출 구조 글(흙막이 · 없음) 그대로 · 되돌림은 52-2 restore + */ +(async () => { + const MODE = window.__m5219; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const pid = localStorage.getItem("frd_current_project_id"); + const api = await import( + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts")) + .pop() + ); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const box = (c) => spec()?.querySelector(`[data-spec-container="${c}"]`); + const ctl = (c, i) => + box(c)?.querySelector(`[data-spec-item="${c}.${i}"]`)?.querySelector("select, input"); + const set = async (c, i, v) => { + const el = ctl(c, i); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(200); + }; + await until(() => + [...document.querySelectorAll(".b05-route__irregular-list li")].some((l) => + /BOX암거$/.test(l.textContent.trim()), + ), + ); + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /BOX암거$/.test(l.textContent.trim()), + ); + (li.querySelector("button") ?? li).click(); + await w(800); + if (!box("body")) (li.querySelector("button") ?? li).click(); + await until(() => !!box("body")); + await w(500); + const read = () => ({ + size: ctl("body", "size")?.value, + width: ctl("body", "width")?.value ?? null, + height: ctl("body", "height")?.value ?? null, + }); + const file = async () => + (await api.fetchDetailPipePoints(pid)).pipe_points.find((p) => p.facility === "box_culvert") + ?.options; + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + return until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + }; + const wings = () => ["inlet", "outlet"].map((c) => ctl(c, "structure")?.value); + const out = { mode: MODE, reopened: read(), wings: wings() }; + if (MODE === "one") { + await set("body", "width", ""); + out.saved = await save(); + out.file = await file(); + } + if (MODE === "both") { + await set("body", "height", ""); + out.saved = await save(); + out.file = await file(); + } + if (MODE === "fill") { + await set("body", "width", "2.2"); + await set("body", "height", "1.6"); + out.saved = await save(); + out.file = await file(); + } + out.wingsAfter = wings(); + out.after = read(); + return JSON.stringify(out); +})(); From 9c20c083276473f4ec14b703dff4bdfbb7b63c4b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 08:10:20 +0900 Subject: [PATCH 27/37] =?UTF-8?q?chore(plan):=2010-6=20=EB=8B=B5=20=C2=B7?= =?UTF-8?q?=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EC=A0=90=EA=B2=80?= =?UTF-8?q?=20=EC=9A=94=EA=B5=AC=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 81999c1c9..7262cb318 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -122,3 +122,13 @@ 맹암거처럼 cad연결이나 횡단도에 그릴지 말지는 이번 수정 후에 작업할거야. 너는 B05/B06 좌측 패널의 생성과 기존 로직 연결에만 신경쓸것. 나는 이제 자러 갈거니 너가 다른 워크트리들을 잘 제어해서 계획서의 내용을 반영하고 최종검증을 진행 후 아티팩트로 결과를 보여주는데 만약 개선이 필요하다고 판단되는 부분이 있다면 계획서에 내용을 추가해서 개선할것. + +(10-6 사용자) +1. 내가 넣어준 선택지 외에는 다른 정보들은 필요없는 상황임. +2. 각 워크트리는 자기의 검증프로젝트를 가지며 수정해도 상관없음. +3. 기타 표가 뭐임? 내가 넣어준거에는 없는데? +4. 물넘이 경사는 내가 다시 요청하겠음. +5. 세월교도 보고 내가 판단하겠음. +6. 현재 만들어진 프로젝트는 이전 로직으로 만들어진 프로젝트로 필요시 재생성 +7. 현재 횡단도의 구조물을 선택하면 좌측 패널 활성화부분 재점검 +8. 좌측 패널의 좌측 하단의 구조물리스트와 하이라이트 기능들 점검 From 771e78ffc2c6c12d070eed8e4e85699b3ef17874 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 08:36:27 +0900 Subject: [PATCH 28/37] =?UTF-8?q?chore(plan):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=ED=91=9C=2008=20=EA=B8=B0=ED=83=80=20=EB=AA=A9?= =?UTF-8?q?=EC=A0=81=20=EC=9B=90=EB=AC=B8(10-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md b/docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md index 8f544555e..4816aadc2 100644 --- a/docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md +++ b/docs/raw/구조물정의_표/08_사토장_토취장_노면공_기타.md @@ -15,3 +15,5 @@ 비고 수기 입력 (상세 설계) 도수로(or 돌붙임) 관보호공, 옹벽, 돌쌓기, 흙막이, 큰톨쌓기 가 같은 형상으로 보임. (가드레일은 표 07 덧과 같음) + +사용자(10-6): 기타 항목의 목적은 우리 프로그램에 등록되지 않은 구조물을 수기입력하여 등록하기 위함임. From 5b351e8c684ff582bb3f3d29ade9268d24f43ab8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 09:16:55 +0900 Subject: [PATCH 29/37] =?UTF-8?q?fix(structure):=20B05=20=C2=B7=20B06=20?= =?UTF-8?q?=EC=A2=8C=EC=B8=A1=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=ED=95=98=EC=9D=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=90=EA=B2=80=20=ED=9D=A0=20=EA=B3=A0=EC=B9=A8?= =?UTF-8?q?(53-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05 목록 · 구조물 알약 누르기에 3D 강조가 안 따라옴 → 목록 고름 · 관 고름이 3D 도 그 측점으로 - 물넘이포장(측점선 없음) 목록 · 알약 · 카드 고름에 알약이 안 켜짐 → 관 알약 id 로 바로 켬 - B06 정규 측점 · 측점 사이 구조물을 고르면 카드가 켜지며 알약이 꺼짐 → 알약은 카드 뒤에 · 측점선 짝 판정이 규칙 측점 기준점도 봄 - B06 목록에서 다시 눌러 풀어도 알약이 남음 → 풂 = 알약도 끔 - B06 목록에서 푼 뒤 같은 카드 관 알약을 누르면 아무것도 안 골라짐 → 카드 다시 고름 - 알약 번호 · 툴팁 · 벌룬이 기타 손 이름 대신 「기타 N」 → 목록과 같은 이름 - 구조물집계표 기타 열 머리: 빈 이름이 「기타 구조물」 · 빈칸 글 → 「기타」(집계 건 이름과 같게) - [저장] 뒤 고른 구조물 강조가 풀림(다시 열면 되살아나 어긋남) → 같은 id 면 그대로 고름 - 목록 줄 마우스 올림 테두리 - 시험: ui_checks/53-2_목록_강조.js · test_53_2_etc_table_name.py · test_design_page_single 판정 자리 옮김 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/design/design_profile.ts | 10 +- A00_Common/design/design_view_profile.ts | 17 +- .../B05_Profile_UI_Profile_Structures.ts | 24 ++ B05_Profile/B05_Profile_UI_Selection.ts | 6 +- B05_Profile/B05_Profile_UI_Structures_List.ts | 4 +- .../B05_Profile_UI_Structures_Marks.ts | 8 +- .../B05_Profile_UI_Structures_Panel.ts | 9 +- B05_Profile/B05_Profile_UI_Style.css | 4 + .../B06_Section_UI_Page_Structures_Panel.ts | 12 +- .../B06_Section_UI_Section_View_Profile.ts | 9 +- M02_MasterTemplete/M02_Table_Fill.py | 6 +- resources/tester/test_53_2_etc_table_name.py | 26 ++ resources/tester/test_design_page_single.py | 6 +- resources/tester/ui_checks/53-2_목록_강조.js | 334 ++++++++++++++++++ 14 files changed, 447 insertions(+), 28 deletions(-) create mode 100644 resources/tester/test_53_2_etc_table_name.py create mode 100644 resources/tester/ui_checks/53-2_목록_강조.js diff --git a/A00_Common/design/design_profile.ts b/A00_Common/design/design_profile.ts index 04ef5de0f..def0e97d1 100644 --- a/A00_Common/design/design_profile.ts +++ b/A00_Common/design/design_profile.ts @@ -53,7 +53,7 @@ import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile import { structureGroupMenuItems, stationIdAtStructure as stationIdAtStructureOf, - structureIdAtStation as structureIdAtStationOf, + structureIdKeeping, } from "../../B05_Profile/B05_Profile_UI_Profile_Structures"; import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view"; import { @@ -261,9 +261,7 @@ export function createDesignProfile(ctx: DesignProfileContext) { /** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로 * 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */ const structureIdAtStation = (stationId: string | null): string | null => - stationId !== null && stationIdAtStructure(selectedStructureId) === stationId - ? selectedStructureId - : structureIdAtStationOf(stationId, irregularStations, structures); + structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail); const stationIdAtStructure = (structureId: string | null): string | null => stationIdAtStructureOf(structureId, irregularStations, structures); @@ -626,8 +624,10 @@ export function createDesignProfile(ctx: DesignProfileContext) { }, /** 사이드 목록에서 고른 구조물을 그래프 알약·측점 세로선 선택에 함께 맞춘다. */ setSelectedStructure(structureId: string | null) { + // 지금 측점(B06 카드)이 그 구조물 자리면 측점선도 그대로(53-2) selectedStructureId = structureId; - selectedStationId = stationIdAtStructure(structureId); + const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId; + selectedStationId = keep ? selectedStationId : stationIdAtStructure(structureId); draw(); }, /** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */ diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 8ca8ae223..6cbbbb4d6 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -31,7 +31,7 @@ import { restoreStructurePick, wireStructurePick, } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; -import { NO_SELECTION, nearestWithin, selectDesign } from "./design_selection"; +import { NO_SELECTION, PIPE_MATCH_M, nearestWithin, selectDesign } from "./design_selection"; import type { DesignPageHost } from "./design_page"; import { bindDesignView, @@ -123,6 +123,15 @@ export async function mountProfileView( profilePanel.setMinCoverPipes(pipes); restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다. }; + /** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2). */ + const selectGraphAt = (id: string | null, chainageM: number | null): void => { + const bare = + id === null && chainageM !== null + ? lastPipes?.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < PIPE_MATCH_M) + : undefined; + if (bare) profilePanel.setSelectedStructure(`pipe-${bare.chainage_m.toFixed(2)}`); + else profilePanel.setSelectedStation(id); + }; // 종단도 한 벌 — 종단 보기가 보일 때 받는 알림만 건다(계획서 24-3b). const profilePanel = createRouteProfilePanel( shared, @@ -205,7 +214,7 @@ export async function mountProfileView( selectionSyncing = value; }, selectMarker: (id) => viewer.markers.selectStation(id), - selectGraph: (id) => profilePanel.setSelectedStation(id), + selectGraph: (id, chainageM) => selectGraphAt(id, chainageM ?? null), selectSidebar: (chainageM) => panel.structures.selectPipeByChainage(chainageM), selectBasin: (chainageM) => profilePanel.drainage.selectBasinByChainage(chainageM), selectPipeForm: (chainageM) => profilePanel.drainage.selectPipeAtChainage(chainageM), @@ -299,10 +308,11 @@ export async function mountProfileView( : nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m); const id = station ? irregularStationId(station.id) : null; viewer.markers.selectStation(id); - profilePanel.setSelectedStation(id); + selectGraphAt(id, chainage); syncBasinHighlight(id); // 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화). profilePanel.drainage.selectPipeAtChainage(chainage); + viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2) } finally { selectionSyncing = false; } @@ -315,6 +325,7 @@ export async function mountProfileView( selectionSyncing = true; try { profilePanel.setSelectedStructure(structure?.structure_id ?? null); + viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null); } finally { selectionSyncing = false; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Structures.ts b/B05_Profile/B05_Profile_UI_Profile_Structures.ts index 4844b20c7..eef902955 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Structures.ts @@ -154,6 +154,30 @@ export function structureIdAtStation( return hit?.structure_id ?? null; } +/** 측점선이 골라질 때 켤 알약 — 지금 고른 알약이 그 측점 자리면 그대로 둔다(같은 측점 + * 여럿 · 24-6b). 규칙 측점(B06 카드)도 기준점 누가거리로 맞춰 본다 — 안 보면 카드가 켜지며 + * 고른 구조물 알약이 꺼졌다(53-2). */ +export function structureIdKeeping( + stationId: string | null, + selectedId: string | null, + stations: IrregularStation[], + structures: StructureInstance[], + detail: { + longitudinal: { stations: ReadonlyArray<{ station_id: string; chainage_m: number }> }; + } | null, +): string | null { + const selected = structures.find((item) => item.structure_id === selectedId); + const at = detail?.longitudinal.stations.find( + (entry) => entry.station_id === stationId, + )?.chainage_m; + const keeps = + stationId !== null && + !!selected && + (stationIdAtStructure(selectedId, stations, structures) === stationId || + (at !== undefined && Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M)); + return keeps ? selectedId : structureIdAtStation(stationId, stations, structures); +} + /** 알약(구조물) id → 측점선 id. 세로선이 없는 구조물(A군 외)이면 null. */ export function stationIdAtStructure( structureId: string | null, diff --git a/B05_Profile/B05_Profile_UI_Selection.ts b/B05_Profile/B05_Profile_UI_Selection.ts index c5fd228ec..d28add866 100644 --- a/B05_Profile/B05_Profile_UI_Selection.ts +++ b/B05_Profile/B05_Profile_UI_Selection.ts @@ -23,8 +23,8 @@ export interface SelectionSyncPorts { setSyncing: (value: boolean) => void; /** 3D 뷰어 마커 선택. */ selectMarker: (stationId: string | null) => void; - /** 종단 그래프 세로선 선택. */ - selectGraph: (stationId: string | null) => void; + /** 종단 그래프 세로선 선택 — 누가거리는 세로선 없는 시설(물넘이포장)의 알약을 켜는 데 씀. */ + selectGraph: (stationId: string | null, chainageM?: number | null) => void; /** 좌측 구조물 폼 선택(누가거리 기준). */ selectSidebar: (chainageM: number | null) => void; /** 배수유역도 세부유역 강조(누가거리 기준). */ @@ -98,7 +98,7 @@ export function createSelectionSync(ports: SelectionSyncPorts): SelectionSync { ports.setSyncing(true); try { ports.selectMarker(id); - ports.selectGraph(id); + ports.selectGraph(id, chainageM); // 투영 측점이 아직 없어도(방금 넣은 임시 관 — 재계산 전) 누가거리는 그대로 // 넘긴다. null을 넘기면 사이드가 "해제"로 받아 임시 배치를 스스로 취소한다 // (2026-08-18 임시 배치 자기 삭제 버그). diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index abb884a5d..ee699ec95 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -56,8 +56,8 @@ export function numberDuplicateNames(names: ReadonlyArray): string[] { }); } -/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름). */ -function etcName(structure: StructureInstance): string | null { +/** 기타 = 사용자가 적은 상세 종류 이름(표 08 · 52-10) — 비었으면 null(종류 이름) · 알약도 씀(53-2). */ +export function etcName(structure: StructureInstance): string | null { const name = structure.type_id === "etc" ? String(structure.options?.name ?? "").trim() : ""; return name || null; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 6b96079a5..e0913e2e7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -19,7 +19,7 @@ import { type StructureInstance, type StructureType, } from "./B05_Profile_Api_Structures"; -import { numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; +import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 @@ -133,7 +133,9 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement (left, right) => structureAnchorM(left) - structureAnchorM(right), ); const numberedNames = numberDuplicateNames( - anchorSorted.map((entry) => typeMap.get(entry.type_id)?.name ?? entry.type_id), + anchorSorted.map( + (entry) => etcName(entry) ?? typeMap.get(entry.type_id)?.name ?? entry.type_id, + ), ); const displayNames = new Map( anchorSorted.map((entry, index) => [entry.structure_id ?? "", numberedNames[index]]), @@ -143,7 +145,7 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement const id = structure.structure_id; if (!id) return; const type = typeMap.get(structure.type_id); - const rawName = type?.name ?? structure.type_id; + const rawName = etcName(structure) ?? type?.name ?? structure.type_id; // 기타 = 손 이름(목록과 같게) const displayName = displayNames.get(id) ?? rawName; // "옹벽 1"에서 붙은 번호만 떼어 약호 뒤에 잇는다(알약은 약호 표기 유지). const numberSuffix = displayName.startsWith(rawName) ? displayName.slice(rawName.length) : ""; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 5eec636e4..160806a93 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -556,11 +556,10 @@ export function createStructuresSection( // 목록에 반영되지 않는다(2026-09-04 실측: 저장 전 항목 [삭제]가 안 먹던 원인). structures.splice(0, structures.length, ...next.map((entry) => ({ ...entry }))); // 실시간 반영으로 되돌아온 목록이면 폼을 닫지 않는다 — 고치던 칸이 사라진다. - if (applyingLive) renderList(); - else { - loadForm(null); - renderList(); - } + // [저장] 뒤 같은 id 가 남으면 그대로 고름 — 다시 열기와 같게(53-2). + const kept = structures.find((entry) => entry.structure_id === editingId) ?? null; + if (!applyingLive) loadForm(editingId === null ? null : kept); + renderList(); }, getStructures: () => [...structures], setPipeFacilities(pipes) { diff --git a/B05_Profile/B05_Profile_UI_Style.css b/B05_Profile/B05_Profile_UI_Style.css index d0b76f534..4ae803d58 100644 --- a/B05_Profile/B05_Profile_UI_Style.css +++ b/B05_Profile/B05_Profile_UI_Style.css @@ -395,6 +395,10 @@ cursor: pointer; } +.b05-route__irregular-item:hover { + border-color: var(--color-royal-amethyst, rgb(109 40 217)); +} + .b05-route__irregular-item.is-selected { border-color: var(--color-royal-amethyst, rgb(109 40 217)); background: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 12%, transparent); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 64f2f4592..d45b00c53 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -172,12 +172,16 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc deps.onStructuresChanged?.(); }, onSelect: (structure) => { - if (!structure) return; - deps.markStructure?.(structure.structure_id ?? null); + if (!structure) { + deps.markStructure?.(null); // 풂 = 알약도 끔(53-2) + return; + } // 카드 고름이 측점으로 다시 풀면 같은 측점 관이 먼저 열린다 — 누른 구조물을 넘긴다. preferredId = structure.structure_id ?? null; deps.focusChainage(structureAnchorM(structure)); preferredId = null; + // 알약은 카드 뒤에 — 카드 측점이 알약을 갈아 끼우거나 끄지 않게(53-2) + deps.markStructure?.(structure.structure_id ?? null); }, getInterval: deps.stationInterval, // 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다. @@ -254,7 +258,6 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { - deps.markStructure?.(null); // 알약 강조는 관 측점선 짝으로(같은 측점 구조물 강조를 푼다). // 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다. selectDesign( deps.projectId, @@ -262,6 +265,8 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc { from: "b06" }, ); if (chainageM !== null) deps.focusChainage(chainageM); + // 알약 = 그 관 알약(같은 측점 구조물 말고 · 측점선 없는 물넘이포장도) — 카드 뒤에(53-2) + deps.markStructure?.(chainageM === null ? null : `pipe-${chainageM.toFixed(2)}`); }, }, }); @@ -491,6 +496,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc if (hit.pipeChainageM !== null) { currentChainageM = hit.pipeChainageM; refreshSpecDefaults(hit.pipeChainageM); + deps.markStructure?.(`pipe-${hit.pipeChainageM.toFixed(2)}`); // 측점선 없는 물넘이포장도(53-2) } section.selectAt(selection); return { chainageM, structureId: hit.structureId }; diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 4a177276d..ce2ace55f 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -196,9 +196,14 @@ export function createSectionProfile( const hit = marks.find((entry) => entry.structure_id === structureId); if (!hit) return; const edit = host.edit(); - if (!isPipeMark(hit) && hit.structure_id && edit) + if (!isPipeMark(hit) && hit.structure_id && edit) { edit.selectStructure(hit.structure_id); - else host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + return; + } + // 목록에서 풀어도 카드는 남음 — 같은 카드면 「다시 보기」만 돼 관이 안 열렸다(53-2) + host.selectCard(null); + host.selectCard(nearestCard(structureAnchorM(hit))?.station_id ?? null); + profile?.setSelectedStructure(structureId); // 측점선 없는 물넘이포장도 그 알약 }, onStructureMarkMove: (structureId, toChainageM) => { const edit = host.edit(); diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 964b1c188..862138d91 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -103,6 +103,10 @@ def collect_items( definition = types.get(item.type_id) if definition is not None and definition.managed_by: continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈 + options = _with_defaults(definition, dict(item.options or {})) + label = etc_label(item.type_id, item.options) + if label: # 표 머리(이름마다 펼침)도 손 이름 · 비면 「기타」(53-2) + options["name"] = label["그룹"] items.append( { "type_id": item.type_id, @@ -110,7 +114,7 @@ def collect_items( "chainage_m": float(item.chainage_m), "start_m": item.start_m, "end_m": item.end_m, - "options": _with_defaults(definition, dict(item.options or {})), + "options": options, "summary_item": item.summary_item, "복사본": item.structure_id or f"#{index}", "group_items": item.group_items, diff --git a/resources/tester/test_53_2_etc_table_name.py b/resources/tester/test_53_2_etc_table_name.py new file mode 100644 index 000000000..166f82c04 --- /dev/null +++ b/resources/tester/test_53_2_etc_table_name.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +"""기타 집계표 머리(이름마다 펼침) = 목록 이름 — 손 이름 · 비면 「기타」 — PLAN 53-2. + +화면은 ORCA `ui_checks/53-2_목록_강조.js`. +""" + +from M02_MasterTemplete import M02_Table_Fill as fill +from resources.tester.test_structures_summary_snapshot import ( # noqa: F401 — world 고정물 + _save, + world, +) + +SOURCE = {"종류": "etc", "펼침": ["name"]} + + +def test_기타_표_머리는_손_이름_비면_기타(world): # noqa: F811 — 고정물 + base = {"type_id": "etc", "placement": "point"} + named = {**base, "structure_id": "e1", "chainage_m": 20.0, "options": {"name": " 배수판 "}} + same = {**base, "structure_id": "e2", "chainage_m": 40.0, "options": {"name": "배수판"}} + space = {**base, "structure_id": "e3", "chainage_m": 60.0, "options": {"name": " "}} + none = {**base, "structure_id": "e4", "chainage_m": 80.0, "options": {}} + _save(world["root"], [named, same, space, none], []) + items = {i["복사본"]: i for i in fill.collect_items(world["root"])} + heads = {key: fill._expansion(SOURCE, items[key]) for key in ("e1", "e2", "e3", "e4")} + assert heads == {"e1": ("배수판",), "e2": ("배수판",), "e3": ("기타",), "e4": ("기타",)} + assert [items[key]["그룹"] for key in ("e1", "e3", "e4")] == ["배수판", "기타", "기타"] diff --git a/resources/tester/test_design_page_single.py b/resources/tester/test_design_page_single.py index e78ba74a2..6cdbdd9a3 100644 --- a/resources/tester/test_design_page_single.py +++ b/resources/tester/test_design_page_single.py @@ -77,7 +77,11 @@ def test_창_키_듣기를_뗌(): def test_겹친_알약은_누른_것을_그대로(): profile = _read("A00_Common/design/design_profile.ts") - assert "stationIdAtStructure(selectedStructureId) === stationId" in profile + assert "structureIdKeeping(stationId, selectedStructureId" in profile + # 판정은 `_Profile_Structures` 로 옮김 — 비정규 측점 짝 · 규칙 측점(B06 카드) 기준점(53-2) + rule = _read("B05_Profile/B05_Profile_UI_Profile_Structures.ts") + assert "stationIdAtStructure(selectedId, stations, structures) === stationId" in rule + assert "Math.abs(structureAnchorM(selected) - at) < SAME_CHAINAGE_M" in rule def test_진행_단계_두_칸이_보기를_따라감(): diff --git a/resources/tester/ui_checks/53-2_목록_강조.js b/resources/tester/ui_checks/53-2_목록_강조.js new file mode 100644 index 000000000..76c58b674 --- /dev/null +++ b/resources/tester/ui_checks/53-2_목록_강조.js @@ -0,0 +1,334 @@ +/* 53-2 — B05 · B06 좌측 하단 구조물 목록 · 하이라이트 전수 점검 + * 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계 · 검증 프로젝트(sub4_검증) · 로그인 · 종단 도크 펼침 + * B05 3D 는 [3D 업데이트] 뒤(코리도 부재 신원표가 있어야 함) + * 자료: 구조물 정본에 표 20종 + 숨긴 종단배수(측구) · 옛 계류사방(골막이 · 기슭막이) · 옛 대피소 · 기타 손 이름 · + * 같은 측점 여럿(4+5 = 배관 · 메쌓기 기준점 · 경계석 / 9+0 = 반사경 · 번호판) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m532 = "단계";` + * names → 목록 이름 = 정본 · 레지스트리로 지은 이름(기준점 차례 · 같은 이름 번호) · 측점 글 + * list → 줄마다 누르기: 그 줄만 강조 · 폼 종류 · 알약 하나(그 구조물) · B05 3D(그 측점 부재) · B06 카드(그 측점) · 다시 누르면 다 풀림 + * pill → 알약마다 누르기: 그 줄 강조 · 목록 칸 안에 보임 · 알약 그대로 켜짐 + * card → (B06) 구조물 기준점 측점 카드 누르기: 그 측점 줄 강조 · 알약 켜짐 + * three → (B05) 3D 부재 누르기(화면 좌표 · 실제 포인터 이벤트): 그 측점 줄 강조 · 목록 칸 안 + * misc → 마우스 올림 규칙(목록 줄 · 알약) · 목록 스크롤(칸보다 김 · 끝 줄 알약 → 보임) + * edit → (B05) 반사경 2+10 [추가] → 차례 · 번호 · 강조 풀림 → 그 줄 [삭제] → 처음 목록 → 옹벽 고르고 [저장] + * → 차례 그대로 · 강조 그대로 (서버 저장 · 검증 프로젝트) + * reopen → (orca reload 뒤 · orca screenshot 한 번 — 안 그려진 탭은 rAF 이 멈춰 「보이게 스크롤」이 안 돎) + * 옹벽 강조 · 알약 되살아남 · 목록 칸 안 + * (B05 · B06 모두 돌려 names · list 결과가 같은지 대조) + * 긴 단계(list · pill · card · three)는 연결이 끊길 수 있음 — `void` 로 띄우고 + * `sessionStorage.getItem("__532_out_{단계}_{b05|b06}")` 로 읽음 + * 쓰는 것: 화면 조작만(sessionStorage 선택 키) — 끝나면 고른 것 풀어 둠 + * ORCA 탭이 안 그려지는 동안 requestAnimationFrame 이 멈춤(목록 「보이게 스크롤」이 그 다음 프레임에 돎) — + * 단계 동안만 setTimeout 으로 대신 돌리고 끝에 되돌림 + * 기대 수치: 모든 단계 fails 0 + */ +(async () => { + const MODE = window.__m532 || "names"; + const raf = window.requestAnimationFrame; + window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const pid = localStorage.getItem("frd_current_project_id"); + const api = async (u) => (await fetch(u, { credentials: "include" })).json(); + const vis = (el) => !!el && el.getClientRects().length > 0; + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const isB06 = location.hash.includes("b06"); + const list = () => document.querySelector(".b05-route__irregular-list"); + const items = () => [...list().querySelectorAll("li.b05-route__irregular-item")]; + const selRows = () => items().filter((li) => li.classList.contains("is-selected")); + const typeSel = () => + document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + const marks = () => [...document.querySelectorAll(".b05-structure__mark")].filter(vis); + const litPills = () => + marks() + .filter((m) => m.classList.contains("is-selected")) + .map((m) => m.dataset.structureId); + const cardSel = () => + [...document.querySelectorAll(".b06-cross-card--selected")] + .filter(vis) + .map((c) => Number(txt(c).split(" ")[1]?.replace("m", ""))); + const tags = () => { + const out = []; + window.__corridorScene?.scene.traverse((o) => { + if (!o.isMesh || typeof o.userData?.chainageM !== "number") return; + const ms = Array.isArray(o.material) ? o.material : [o.material]; + out.push({ o, c: o.userData.chainageM, lit: ms.some((m) => m.emissive?.getHex()) }); + }); + return out; + }; + const lit3D = () => [ + ...new Set( + tags() + .filter((t) => t.lit) + .map((t) => t.c.toFixed(2)), + ), + ]; + const inView = (li) => { + const a = li.getBoundingClientRect(); + const b = list().getBoundingClientRect(); + return a.top >= b.top - 1 && a.bottom <= b.bottom + 1; + }; + + // 기대 목록 — 정본 둘(구조물 · 관 지점)을 기준점 오름차순으로(목록과 같은 규칙) + const types = (await api("/api/projects/structure-types?include_disabled=true")).types; + const typeName = (id) => types.find((t) => t.type_id === id)?.name ?? id; + const interval = 20; + const sta = (m) => { + let n = Math.floor((m + 1e-6) / interval); + let r = m - n * interval; + if (Math.abs(r) < 0.05) r = 0; + if (r >= interval - 0.05) ((n += 1), (r = 0)); + return `${n}+${Math.round(r)}`; + }; + const structures = (await api(`/api/projects/${pid}/route/structures`)).structures; + const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + const rows = [ + ...structures.map((s) => ({ + anchor: s.chainage_m ?? s.start_m, + type: s.type_id === "refuge" ? "evac_road" : s.type_id, // 옛 대피소는 대피로 폼으로 엶(52-7) + pill: s.structure_id, + name: (s.type_id === "etc" && String(s.options?.name ?? "").trim()) || typeName(s.type_id), + station: s.placement === "interval" ? `${sta(s.start_m)}~${sta(s.end_m)}` : sta(s.chainage_m), + })), + ...pipes.map((p) => ({ + anchor: p.chainage_m, + type: p.facility ?? "pipe", + pill: `pipe-${p.chainage_m.toFixed(2)}`, + name: typeName(p.facility ?? "pipe"), + station: sta(p.chainage_m), + })), + ].sort((a, b) => a.anchor - b.anchor); + const total = {}; + rows.forEach((r) => (total[r.name] = (total[r.name] ?? 0) + 1)); + const seen = {}; + rows.forEach((r) => { + if (total[r.name] > 1) r.name = `${r.name} ${(seen[r.name] = (seen[r.name] ?? 0) + 1)}`; + r.text = `${r.station} ${r.name}`; + }); + + const clearPick = async () => { + for (let k = 0; k < 2 && selRows().length; k++) { + selRows()[0].click(); + await w(900); + } + }; + const out = { mode: MODE, view: isB06 ? "b06" : "b05", fails: [] }; + const fail = (what) => out.fails.push(what); + if (MODE === "reopen") { + // 다시 연 화면 — 저장 전에 고른 것이 되살아났는가(세션 고름) + const sel = selRows(); + out.restored = sel.map(txt); + if (sel.length !== 1 || !/옹벽$/.test(txt(sel[0]))) fail(`되살린 강조 ${out.restored}`); + else if (!inView(sel[0])) fail("되살린 줄이 목록 칸 밖"); + const pills = litPills(); + if (pills.length !== 1 || pills[0] !== rows.find((r) => /옹벽$/.test(r.text))?.pill) + fail(`되살린 알약 ${pills}`); + } + await clearPick(); + + if (MODE === "names") { + const got = items().map(txt); + out.rows = got.length; + out.expected = rows.length; + rows.forEach((r, i) => got[i] !== r.text && fail(`${i}: ${got[i]} ≠ ${r.text}`)); + out.names = got; + } else if (MODE === "list") { + const tagCs = new Set(tags().map((t) => t.c.toFixed(2))); + for (const [i, r] of rows.entries()) { + items()[i].click(); + await w(isB06 ? 1500 : 900); + const sel = selRows(); + if (sel.length !== 1 || sel[0] !== items()[i]) fail(`${r.text}: 줄 강조 ${sel.map(txt)}`); + if (typeSel()?.value !== r.type) fail(`${r.text}: 폼 종류 ${typeSel()?.value}`); + const pills = litPills(); + if (pills.length !== 1 || pills[0] !== r.pill) fail(`${r.text}: 알약 ${pills}`); + if (!isB06) { + const want = [...tagCs].filter((c) => Math.abs(c - r.anchor) < 0.01); + const got = lit3D(); + if (want.join() !== got.join()) fail(`${r.text}: 3D ${got} ≠ ${want}`); + } else { + // 카드 = 기준점에 가장 가까운 측점 하나(카드 글 「측점 누가거리m …」) + const card = cardSel(); + if (card.length !== 1 || Math.abs(card[0] - r.anchor) > interval / 2) + fail(`${r.text}: 카드 ${card}`); + } + items()[i].click(); // 다시 누름 = 풂 + await w(900); + if (selRows().length || litPills().length || (!isB06 && lit3D().length)) + fail(`${r.text}: 풂 뒤 남음 ${selRows().length}/${litPills()}/${lit3D()}`); + } + out.checked = rows.length; + } else if (MODE === "pill") { + for (const r of rows) { + const pill = marks().find((m) => m.dataset.structureId === r.pill); + if (!pill) { + fail(`${r.text}: 알약 없음`); + continue; + } + pill.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(isB06 ? 1500 : 900); + const sel = selRows(); + if (sel.length !== 1 || txt(sel[0]) !== r.text) fail(`${r.text}: 줄 ${sel.map(txt)}`); + else if (!inView(sel[0])) fail(`${r.text}: 목록 칸 밖`); + if (litPills().join() !== r.pill) fail(`${r.text}: 알약 ${litPills()}`); + await clearPick(); + } + out.checked = rows.length; + } else if (MODE === "card") { + // 측점마다 카드 하나 — 기준점이 그 측점인 줄 중 하나가 켜져야 함(같은 측점 여럿은 52-15 규칙) + const cards = [...document.querySelectorAll(".b06-cross-card")]; + let n = 0; + for (const card of cards) { + const at = Number(txt(card).split(" ")[1]?.replace("m", "")); + const here = rows.filter((r) => Math.abs(r.anchor - at) < 0.51); + if (!here.length) continue; + // 관 시설 범위 안 카드는 관이 먼저(계곡 통과 시설 우선 · resolveSelection) + here.push(...rows.filter((r) => r.pill.startsWith("pipe-") && Math.abs(r.anchor - at) < 5)); + n += 1; + card.scrollIntoView({ block: "nearest" }); + await w(300); + card.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(1500); + const sel = selRows(); + const hit = here.find((r) => sel.length === 1 && txt(sel[0]) === r.text); + if (!hit) fail(`${at}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`); + else if (litPills().join() !== hit.pill) fail(`${at}: 알약 ${litPills()} ≠ ${hit.pill}`); + if (sel.length && !inView(sel[0])) fail(`${at}: 목록 칸 밖`); + await clearPick(); + } + out.checked = n; + } else if (MODE === "three") { + const S = window.__corridorScene; + const canvas = [...document.querySelectorAll("canvas")] + .filter(vis) + .sort((a, b) => b.width * b.height - a.width * a.height)[0]; + const done = new Set(); + let n = 0; + // 종단 도크가 캔버스 아래를 덮음 — 단계 동안 접고 끝에 다시 폄 + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + handle?.click(); + await w(1500); + const o = S.toScene(0, 0, 0); // 장면 → 모델: x = x − o.x · y = −(z − o.z) · z = y − o.y + for (const t of tags()) { + const key = t.c.toFixed(2); + if (done.has(key)) continue; + const box = new S.THREE.Box3().setFromObject(t.o); + const c = box.getCenter(new S.THREE.Vector3()); + S.lookAt(c.x - o.x, -(c.z - o.z), c.y - o.y, 25, "top"); // 그 부재 위에서 내려다봄 + S.camera.updateMatrixWorld(); + const p = c.clone().project(S.camera); + const rect = canvas.getBoundingClientRect(); + const x = rect.left + ((p.x + 1) / 2) * rect.width; + const y = rect.top + ((1 - p.y) / 2) * rect.height; + const top = document.elementFromPoint(x, y); + if (top !== canvas) continue; // 패널에 가린 부재는 건너뜀 + const ray = new S.THREE.Raycaster(); + ray.setFromCamera(new S.THREE.Vector2(p.x, p.y), S.camera); + const first = ray.intersectObject(S.scene, true).find((h) => h.object.isMesh); + if (first?.object.userData?.chainageM !== t.c) continue; // 앞에 다른 물체 + done.add(key); + n += 1; + const ev = (type) => + new PointerEvent(type, { + bubbles: true, + clientX: x, + clientY: y, + button: 0, + pointerId: 7, + isPrimary: true, + }); + window.dispatchEvent(ev("pointerdown")); + canvas.dispatchEvent(ev("pointerdown")); + canvas.dispatchEvent(ev("pointerup")); + await w(1200); + const sel = selRows(); + const here = rows.filter((r) => Math.abs(r.anchor - t.c) < 0.51); + if (!here.some((r) => sel.length === 1 && txt(sel[0]) === r.text)) + fail(`3D ${key}: 줄 ${sel.map(txt)} (후보 ${here.map((r) => r.text)})`); + else if (!inView(sel[0])) fail(`3D ${key}: 목록 칸 밖`); + if (!lit3D().includes(key)) fail(`3D ${key}: 부재 강조 ${lit3D()}`); + await clearPick(); + } + handle?.click(); + await w(1500); + out.tagged = new Set(tags().map((t) => t.c.toFixed(2))).size; + out.checked = n; + } else if (MODE === "misc") { + const rules = [...document.styleSheets].flatMap((s) => { + try { + return [...s.cssRules].map((r) => r.selectorText ?? ""); + } catch { + return []; + } + }); + if (!rules.includes(".b05-route__irregular-item:hover")) fail("목록 줄 마우스 올림 규칙 없음"); + if (!rules.includes(".b05-structure__mark:hover")) fail("알약 마우스 올림 규칙 없음"); + const l = list(); + out.scroll = { scrollH: l.scrollHeight, clientH: l.clientHeight }; + if (l.scrollHeight <= l.clientHeight) fail("목록이 칸보다 짧음 — 스크롤 시험 안 됨"); + for (const r of [rows[rows.length - 1], rows[0], rows[Math.floor(rows.length / 2)]]) { + marks() + .find((m) => m.dataset.structureId === r.pill) + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(isB06 ? 1500 : 900); + const sel = selRows()[0]; + if (!sel || !inView(sel)) fail(`${r.text}: 스크롤 뒤 안 보임`); + await clearPick(); + } + out.checked = 5; + } else if (MODE === "edit") { + // 추가(반사경 2+10 · 폼) → 차례 · 강조 풀림 → 그 줄 고르고 삭제 → 처음과 같음 → 옹벽 고르고 [저장] + const before = items().map(txt); + const sec = document.querySelector(".b05-structure-section"); + const fire = (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const [g, t] = sec.querySelectorAll(".b05-structure__grid select"); + fire(g, "E"); + await w(300); + fire(t, "mirror"); + await w(800); + const anchor = sec + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input"); + fire(anchor[0], "2"); + fire(anchor[1], "10"); + await w(300); + sec.querySelector(".is-primary").click(); // 추가 + await w(1500); + const added = items().map(txt); + out.added = added.slice(0, 3); + if (added.length !== before.length + 1) fail(`추가 뒤 줄 수 ${added.length}`); + if (added[1] !== "2+10 반사경 1" || !added.includes("9+0 반사경 2")) + fail(`추가 차례 · 번호 ${added.slice(0, 3)} · 9+0 ${added.find((x) => x.startsWith("9+0"))}`); + if (selRows().length || litPills().length) fail("추가 뒤 강조 남음"); + items()[1].click(); + await w(900); + if (txt(selRows()[0] ?? list()) !== "2+10 반사경 1") fail("새 줄 고르기"); + if (litPills().length !== 1) fail(`새 줄 알약 ${litPills()}`); + sec.querySelector(".is-danger").click(); // 삭제 + await w(1500); + const removed = items().map(txt); + if (removed.join("|") !== before.join("|")) fail("삭제 뒤 목록이 처음과 다름"); + if (selRows().length || litPills().length) fail("삭제 뒤 강조 남음"); + // 옹벽 고른 채 [저장] — 저장 뒤 차례 그대로 · 강조 그대로 + items() + .find((li) => /옹벽$/.test(txt(li))) + .click(); + await w(900); + [...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click(); + for (let k = 0; k < 120 && !document.querySelector(".ui-toast, [class*='toast']"); k++) + await w(500); + await w(3000); + out.toast = document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null; + const saved = items().map(txt); + if (saved.join("|") !== before.join("|")) fail("저장 뒤 목록 차례가 다름"); + out.afterSave = selRows().map(txt); + if (out.afterSave.length !== 1 || !/옹벽$/.test(out.afterSave[0])) fail("저장 뒤 강조 풀림"); + out.checked = 9; + } + window.requestAnimationFrame = raf; + out.failCount = out.fails.length; + sessionStorage.setItem(`__532_out_${MODE}_${out.view}`, JSON.stringify(out)); // 긴 단계는 여기서 읽음 + return JSON.stringify(out); +})(); From bcc3cbb407b7b30683f181e91d6b145d2fdc5cea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 09:52:23 +0900 Subject: [PATCH 30/37] =?UTF-8?q?chore(plan):=2010-6=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=EB=B3=84=20=EC=88=98=EC=A0=95=20=EC=9A=94=EC=B2=AD=20?= =?UTF-8?q?1=EC=B0=A8(=EA=B3=B5=ED=86=B5=20=C2=B7=20=EB=B0=B0=EA=B4=80)=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 7262cb318..2b12931aa 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -132,3 +132,20 @@ 6. 현재 만들어진 프로젝트는 이전 로직으로 만들어진 프로젝트로 필요시 재생성 7. 현재 횡단도의 구조물을 선택하면 좌측 패널 활성화부분 재점검 8. 좌측 패널의 좌측 하단의 구조물리스트와 하이라이트 기능들 점검 + +(10-6 사용자 · 구조물별 수정 요청 1차) +1. 공통 + - 좌측 패널의 컨테이너 내부 구조물 조합 항목의 고르기 버튼의 이름은 '선택'으로 변경 + - 패널 내부 설명은 공간이 부족하니 별도로 빼서 작성할 예정 ? 버튼으로 연결하여 별도의 모달창으로 작업예정이며 각페이지에는 설명서를 넣을 예정. 내부에 들어갈 말은 나도 수정할 수 있게 별도로 페이지별 항목별 별도의 파일로 관리. 여기서 ? 버튼의 위치는 좌측 패널의 좌측하단 초기화 버튼이 있는 그룹들 중 제일 좌측에 존재. 다른 B02 ~ B09 까지 공통사항 + - 횡단도 +2. 배관 + - 관컨테이너 : 관 길이의 사용자의 입력을 받는 정보가 아니라 보여주는 정보임. 라벨은 유지하고 내부 내용은 자동계산으로 변경, 관 길이 항목은 소수점 없음 + - 관 보호공(유입) 컨테이너 : + ㄱ. 집수정은 길이, 기준측점 전/후의 내용이 필요 없음. + ㄴ. 높이에 따라 집수정의 형상이 변경 연결이 안되어 있음. + ㄷ. 흙막이의 돌쌓기(찰)의 경우 다른 형태 대비 모양이 변함. 모양은 같고 형태에 따라 내부 모양만 변하는게 맞음. + - 관 보호공(유출) : + ㄱ. '관길이 15m 좌우 자동 자리 ...' 설명 필요 없음 + ㄴ. 지난 정의 값이라는 항목은 필요없음. 향후 다 정리하고 사용하지 않는 코드들은 삭제 예정 + ㄷ. 추가 기슭막이(단)이 흙막이 추가 흙막이 추가에 대한 내용이었음. 별도 버튼으로 추가하는게 아님. 상단 컨테이너의 흙막이 옵션중 추가 기슭막이(단)의 값에 따라 생성되는데 이때 추가되는 기슭막이의 이름을 흙막이로 변경하고 선택되었을때 보여주는게 아니라 항상 보이게 + ㄹ. 도수로/돌붙임은 일단 유지 From 6233daab11527c989bee46a35769803697130437 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 09:57:43 +0900 Subject: [PATCH 31/37] =?UTF-8?q?chore(plan):=2010-6=20=EA=B3=B5=ED=86=B5?= =?UTF-8?q?=20=EB=8D=A7(=EC=A2=85=EB=A5=98=20=EB=B0=94=EA=BE=B8=EA=B8=B0?= =?UTF-8?q?=20=ED=9A=A1=EB=8B=A8=20=EA=B7=B8=EB=A6=BC=20=C2=B7=20=EC=86=8D?= =?UTF-8?q?=EB=8F=84=20=C2=B7=20=EB=B3=B4=EC=95=88=20=EC=9E=AC=ED=99=95?= =?UTF-8?q?=EC=9D=B8)=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 2b12931aa..f1a32c755 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -149,3 +149,8 @@ ㄴ. 지난 정의 값이라는 항목은 필요없음. 향후 다 정리하고 사용하지 않는 코드들은 삭제 예정 ㄷ. 추가 기슭막이(단)이 흙막이 추가 흙막이 추가에 대한 내용이었음. 별도 버튼으로 추가하는게 아님. 상단 컨테이너의 흙막이 옵션중 추가 기슭막이(단)의 값에 따라 생성되는데 이때 추가되는 기슭막이의 이름을 흙막이로 변경하고 선택되었을때 보여주는게 아니라 항상 보이게 ㄹ. 도수로/돌붙임은 일단 유지 + +(10-6 사용자 · 공통 덧) +공통 + - 기존 배관에서 박스암거로 변경할 경우 횡단도의 그림이 업데이트 안됨. 확인 및 업데이트 + - 추가로 관련된 정보 중 확인해줘야 할 내용이 기존에 화면의 B05/B06의 실시간 제어와 동작 속도/보안 개선을 위해 많은 작업이 있었는데 좌측 패널 통합 이후 많이 느려진 걸로 보임. 재확인. 이건 확인부터 하는게 좋겠음. From 2256f2f4fad3a6b90fd84c1ce825e9f1bb6fed27 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:06:49 +0900 Subject: [PATCH 32/37] =?UTF-8?q?fix(section):=20B06=20=EB=B0=B0=EA=B4=80?= =?UTF-8?q?=20=ED=9A=A1=EB=8B=A8=EB=8F=84=20=EC=A7=91=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=86=92=EC=9D=B4=20=EC=97=B0=EA=B2=B0=20=C2=B7=20=ED=9D=99?= =?UTF-8?q?=EB=A7=89=EC=9D=B4=20=ED=98=95=ED=83=9C=EB=B3=84=20=EC=99=B8?= =?UTF-8?q?=EA=B3=BD=20=ED=86=B5=EC=9D=BC(54-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 폼 집수정 「높이」(inlet_basin_height_m)를 관 제원 basin_height_m 로 실음(파이썬 · TS 짝 · 폼 → 캐시) — I · L · ㄷ형 벽 높이가 그 값을 따름 · 비면 종전 자동 - 배관 벽 · 다단 벽 전면 기울기를 형태와 무관한 기본 형태 표준경사로(outlineLeanOf) — 형태는 안 무늬만 다름 - 3D 코리도 해시에 집수정 높이 더함 - ui_checks/54-3_배관_횡단그림.js — 높이 1.0 → 2.0 비 2.0 · 여섯 형태 외곽 같음 · 무늬 6 가지 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- ...B05_Profile_UI_Corridor_Structures_Hash.ts | 1 + B06_Section/B06_Section_Api_Types.ts | 2 + B06_Section/B06_Section_Engine_Culvert.py | 2 + .../B06_Section_UI_Cross_Culvert_Basin.ts | 8 +- .../B06_Section_UI_Cross_Culvert_Geom.ts | 17 +- B06_Section/B06_Section_UI_Cross_Lean.ts | 6 + .../B06_Section_UI_Page_Pipe_Options.ts | 1 + common_util/common_util_culvert_sets.ts | 1 + .../tester/ui_checks/54-3_배관_횡단그림.js | 194 ++++++++++++++++++ 9 files changed, 223 insertions(+), 9 deletions(-) create mode 100644 resources/tester/ui_checks/54-3_배관_횡단그림.js diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts index 8569f1579..fa25e4f4d 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures_Hash.ts @@ -88,6 +88,7 @@ export function structureHashParts(section: CrossSection): Array 0 ? culvert.inlet.basin_height_m : null; // 좌표 규약: +offset = 좌측. 상단측 = 유입(미상이면 좌측 폴백). const uphill = section.uphill_side ?? "left"; @@ -157,7 +159,7 @@ export function computeCulvertLayout( const basinReason = culvert.hidden_pipe ? null : choiceReason; // ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치 — 바닥(=관 invert)이 따라 오른다. if (basinReason && basinShape !== "I") { - inlet.elevation = inletInfo.edge.elevation_m - basinAdjust.innerHeightM; + inlet.elevation = inletInfo.edge.elevation_m - (basinHeightM ?? basinAdjust.innerHeightM); } else if (basinReason) { // I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내). const startOffset = @@ -172,10 +174,10 @@ export function computeCulvertLayout( const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) }); const adjInlet = adjustOf(revetShift?.inlet); const adjOutlet = adjustOf(revetShift?.outlet); - // 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5). + // 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 높이) — 형태는 안 무늬만(외곽 같음). const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => { const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter); - const lean = leanOf({ + const lean = outlineLeanOf({ form: wall.form, height_m: wall.pureHeight, section_mode: section.design?.section_mode, @@ -343,6 +345,7 @@ export function computeCulvertLayout( groundAt, cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0, adjust: basinAdjust, + heightM: basinHeightM, }); basin = built.basin; basinPipeEnd = built.pipeEnd; @@ -624,9 +627,9 @@ export function computeCulvertLayout( ) : null; - // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈). + // 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 높이마다 표준경사(형태 무관 · B08 은 다단을 안 셈). const extraLean = (form: string, pureHeightM: number): number => - leanOf({ + outlineLeanOf({ form, height_m: pureHeightM, section_mode: section.design?.section_mode, diff --git a/B06_Section/B06_Section_UI_Cross_Lean.ts b/B06_Section/B06_Section_UI_Cross_Lean.ts index e6daa9d8d..5607cab81 100644 --- a/B06_Section/B06_Section_UI_Cross_Lean.ts +++ b/B06_Section/B06_Section_UI_Cross_Lean.ts @@ -11,8 +11,14 @@ import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope"; import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json"; +import { REVET_FORM_DEFAULT } from "./B06_Section_UI_Cross_Culvert_Const"; /** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */ export function leanOf(input: WallLeanInput): number { return wallLeanRatio(masonrySlope, input); } + +/** 배관 흙막이 외곽 기울기 — 형태와 무관(형태는 안 무늬만) · 기본 형태의 표준경사. */ +export function outlineLeanOf(input: WallLeanInput): number { + return leanOf({ ...input, form: REVET_FORM_DEFAULT }); +} diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 73970a199..1d3c8d98f 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -132,6 +132,7 @@ export function applyPipeOptionsToCache( own(culvert.inlet, "basin_length_m", num("inlet_basin_length_m")); own(culvert.inlet, "basin_before_m", num("inlet_basin_before_m")); own(culvert.inlet, "basin_after_m", num("inlet_basin_after_m")); + own(culvert.inlet, "basin_height_m", num("inlet_basin_height_m")); // 연장이 바뀌면 옆 측점 링크도 달라진다 — 그 구조물이 덮는 범위만 다시 그린다. const reach = Math.max( culvert.inlet.revet_before_m ?? 0, diff --git a/common_util/common_util_culvert_sets.ts b/common_util/common_util_culvert_sets.ts index b9fb617e7..296dcf02c 100644 --- a/common_util/common_util_culvert_sets.ts +++ b/common_util/common_util_culvert_sets.ts @@ -108,6 +108,7 @@ function sideSpec( num(defaults.inlet_basin_before_m, null), ); spec.basin_after_m = num(options.inlet_basin_after_m, num(defaults.inlet_basin_after_m, null)); + spec.basin_height_m = num(options.inlet_basin_height_m, null); return spec; } diff --git a/resources/tester/ui_checks/54-3_배관_횡단그림.js b/resources/tester/ui_checks/54-3_배관_횡단그림.js new file mode 100644 index 000000000..71ac4f824 --- /dev/null +++ b/resources/tester/ui_checks/54-3_배관_횡단그림.js @@ -0,0 +1,194 @@ +/* 54-3 — B06 횡단도 배관 그림: 집수정 높이 → 형상 · 흙막이 여섯 형태 = 외곽 같음 · 안 무늬만 다름 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub7_검증) · 목록에 「배관 1」(4+5) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m543 = "단계";` + * basin → 유입 구조 집수정 · I · L · ㄷ형마다 높이 1.0 → 2.0 → 그림 높이(m, SVG 실좌표 ÷ 세로 눈금) + * soil → 유입 · 유출 흙막이 형태 여섯 → 벽 외곽 꼭짓점(SVG) 같음 · 무늬(해칭 묶음) 서로 다름 + * extra → 추가 흙막이(다단) — 상세의 배관 측점마다 기하를 형태 여섯으로 돌려 외곽 꼭짓점 같음 + * 쓰는 것: 좌측 폼 값(캐시만 · 저장 안 함) — 단계 끝에 원래 값으로 되돌림 + * 기대 수치: basin 형태마다 h1 ≈ 1.0 · h2 ≈ 2.0 · ratio ≈ 2.0 / soil sameOutline true · distinctHatch 6 / + * extra sameLean true · sameOutlineAtSameHeight true(다단이 선 측점 1곳 이상) · heightCapped = 지형 맞춤 키가 + * 형태 높이 한계에 걸린 측점(키만 다름) + */ +(async () => { + const MODE = window.__m543 || "basin"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const ctl = (c, item) => + spec() + ?.querySelector(`[data-spec-container="${c}"] [data-spec-item="${c}.${item}"]`) + ?.querySelector("select, input"); + const set = async (c, item, v) => { + const el = ctl(c, item); + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(900); + }; + const pickPipe = async () => { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + if (spec()) return; + const li = [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + /배관 1$/.test(l.textContent.trim()), + ); + (li.querySelector("button") ?? li).click(); + await until(() => !!spec()); + await w(800); + }; + const card = () => document.querySelector(".b06-cross-card--selected svg"); + /** 세로 눈금 — 같은 x 에 놓인 숫자 글(표고)에서 1m 당 픽셀. */ + const pxPerM = () => { + const nums = [...card().querySelectorAll("text")] + .filter((t) => /^-?\d+(\.\d+)?$/.test(t.textContent.trim())) + .map((t) => ({ + v: Number(t.textContent), + x: t.getAttribute("x"), + y: Number(t.getAttribute("y")), + })); + const byX = {}; + for (const n of nums) (byX[n.x] ??= []).push(n); + const col = Object.values(byX) + .filter((g) => new Set(g.map((n) => n.y)).size > 1) + .sort((a, b) => b.length - a.length)[0]; + const a = col[0]; + const b = col[col.length - 1]; + return Math.abs(b.y - a.y) / Math.abs(b.v - a.v); + }; + const pts = (poly) => + poly + .getAttribute("points") + .trim() + .split(/\s+/) + .map((p) => p.split(",").map(Number)); + const minY = (poly) => Math.min(...pts(poly).map((p) => p[1])); + const round = (v) => Math.round(v * 100) / 100; + const out = { mode: MODE }; + await pickPipe(); + + if (MODE === "basin") { + const keep = ["structure", "form", "height"].map((k) => ctl("inlet", k).value); + await set("inlet", "structure", "집수정"); + out.shapes = {}; + for (const shape of ["I", "L", "U"]) { + await set("inlet", "form", shape); + const hs = []; + for (const h of ["1.0", "2.0"]) { + await set("inlet", "height", h); + const k = pxPerM(); + const parts = [...card().querySelectorAll(".b06-chart__culvert-basin")]; + const title = (p) => p.querySelector("title")?.textContent ?? ""; + const wall = parts.find((p) => / 벽 /.test(title(p))); + const floor = parts.find((p) => / 바닥 /.test(title(p))); + const label = [...card().querySelectorAll(".b06-chart__culvert-label")].find((t) => + t.textContent.startsWith("집수정"), + ); + // ㄴ · ㄷ = 바닥 윗면 ~ 벽 상단 · I = 관 유입 기준(라벨 자리 + 0.2) ~ 벽 상단 + const base = floor ? minY(floor) : Number(label.getAttribute("y")) - 11 - 0.2 * k; + hs.push({ name: title(wall).match(/\((.+?)\)/)?.[1], h: round((base - minY(wall)) / k) }); + } + out.shapes[shape] = { + name: hs[0].name, + h1: hs[0].h, + h2: hs[1].h, + ratio: round(hs[1].h / hs[0].h), + }; + } + await set("inlet", "structure", keep[0]); + await set("inlet", "form", keep[1]); + await set("inlet", "height", keep[2]); + out.restored = ["structure", "form", "height"].map((k) => ctl("inlet", k).value); + } + + if (MODE === "soil") { + for (const [side, word] of [ + ["inlet", "유입"], + ["outlet", "유출"], + ]) { + // 구조가 비면 형태 칸이 안 섬 — 흙막이로 고른 뒤 끝에 되돌림 + const keepStructure = ctl(side, "structure").value; + if (keepStructure !== "기슭막이") await set(side, "structure", "기슭막이"); + const forms = [...ctl(side, "form").options].map((o) => o.value).filter(Boolean); + out.forms = forms; + const keep = ctl(side, "form").value; + const rows = []; + for (const form of forms) { + await set(side, "form", form); + const poly = [...card().querySelectorAll(".b06-chart__culvert-revet")].find((p) => + (p.querySelector("title")?.textContent ?? "").startsWith(`${word} 기슭막이`), + ); + const hatch = poly.nextElementSibling?.nextElementSibling; + rows.push({ + form, + title: poly.querySelector("title").textContent.slice(0, 40), + outline: pts(poly) + .map((p) => p.map(round).join(",")) + .join(" "), + hatch: `${hatch?.childElementCount}:${hatch?.innerHTML.length}`, + }); + } + await set(side, "form", keep); + if (keepStructure !== "기슭막이") await set(side, "structure", keepStructure); + out[side] = { + sameOutline: rows.every((r) => r.outline === rows[0].outline), + distinctHatch: new Set(rows.map((r) => r.hatch)).size, + outline: rows[0].outline, + rows: rows.map((r) => [r.form, r.hatch, r.title]), + restored: ctl(side, "structure").value === keepStructure, + }; + } + } + + if (MODE === "extra") { + const modUrl = (part) => + performance + .getEntriesByType("resource") + .map((e) => e.name) + .filter((n) => n.includes(part)) + .pop(); + const geom = await import(modUrl("/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts")); + const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail")); + const detail = JSON.parse(sessionStorage.getItem(key)); + const forms = geom.REVET_FORMS; + out.checked = 0; + out.sameLean = true; + out.sameOutlineAtSameHeight = true; + // 지형 맞춤 키는 형태 높이 한계(메 2.0 · 찰 3.0 · 콘 5.0 — 교본)에서 멈춤 — 같은 키끼리만 외곽 대조 + out.heightCapped = []; + for (const section of detail.cross_sections ?? detail.value?.cross_sections ?? []) { + if (!section.culvert || section.culvert.hidden_pipe) continue; + const rows = forms.map((m) => { + const walls = + geom.computeCulvertLayout(section, section.samples, { extras: [{ m }, { m }] }) + ?.extraWalls ?? []; + return { + m, + lean: walls.map((wall) => wall.lean).join(","), + height: walls.map((wall) => round(wall.height)).join(","), + outline: JSON.stringify( + walls.map((wall) => wall.points.map((p) => [round(p.offset), round(p.elevation)])), + ), + }; + }); + if (rows[0].outline === "[]") continue; + out.checked += 1; + if (!rows.every((r) => r.lean === rows[0].lean)) out.sameLean = false; + for (const r of rows) { + const twin = rows.find((o) => o.height === r.height); + if (twin.outline !== r.outline) out.sameOutlineAtSameHeight = false; + } + const heights = [...new Set(rows.map((r) => r.height))]; + if (heights.length > 1) + out.heightCapped.push({ + chainage: section.chainage_m, + byForm: rows.map((r) => `${r.m}=${r.height}`), + }); + } + } + return JSON.stringify(out); +})(); From 73e8d4ea5663c5afc035d352f22e4c59ff50b5b8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:17:07 +0900 Subject: [PATCH 33/37] =?UTF-8?q?feat(help):=20B02~B09=20=EC=A2=8C?= =?UTF-8?q?=EC=B8=A1=20=ED=8C=A8=EB=84=90=20[=3F]=20=EC=84=A4=EB=AA=85?= =?UTF-8?q?=EC=84=9C=20=EB=AA=A8=EB=8B=AC=20=C2=B7=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=95=88=20=EC=84=A4=EB=AA=85=20=EA=B8=80=EC=9D=84=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EB=A1=9C=20=EC=98=AE=EA=B9=80(54-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [?] = 좌측 패널 아래 단추 묶음 맨 왼쪽(공용 한 벌 · B02 는 아래 단추 줄 · B08·B09 는 [?] 줄만) - 모달 = 왼쪽 차례 · 오른쪽 글 · 고른 구조물 종류 · 연 상자 항목 먼저 - 글 = resources/help//*.md · 서버가 열 때마다 읽음(파일만 고치면 반영) · 설명서는 뼈대 - 옮김 = B03 안내 셋 · B05 구조물 저장 규칙 · 측구 주인 · 기슭막이 근거 · 칸 기본값 근거 · B06 표준 횡단면 두 줄 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- A00_Common/design/design_dock.ts | 1 + B02_ProjRegister/B02_ProjRegister_UI_Page.ts | 4 +- B03_FileInput/B03_FileInput_UI_Guide.ts | 75 +-------- B03_FileInput/B03_FileInput_UI_Style.css | 35 +---- .../B05_Profile_UI_Drainage_Facility.ts | 7 +- ...B05_Profile_UI_Drainage_Facility_Fields.ts | 24 +-- .../B05_Profile_UI_Structures_Fields.ts | 14 +- B05_Profile/B05_Profile_UI_Structures_Form.ts | 11 -- .../B05_Profile_UI_Structures_Panel.ts | 9 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 6 +- .../B05_Profile_UI_Style_Structures.css | 7 - .../B06_Section_UI_Standard_Diagram.ts | 5 +- B06_Section/B06_Section_UI_Standard_Panel.ts | 5 +- B06_Section/B06_Section_UI_Style.css | 12 -- B07_DesignDetail/B07_DesignDetail_UI_Page.ts | 1 + common_util/common_util_help_router.py | 34 ++++ main.py | 3 + resources/help/B02/00_설명서.md | 9 ++ resources/help/B03/00_설명서.md | 9 ++ resources/help/B03/10_필요한_파일.md | 5 + resources/help/B03/20_고르는_방법.md | 5 + resources/help/B03/30_알아_둘_것.md | 5 + resources/help/B04/00_설명서.md | 9 ++ resources/help/B05/00_설명서.md | 9 ++ resources/help/B05/구조물_BOX암거.md | 5 + resources/help/B05/구조물_기슭막이.md | 13 ++ resources/help/B05/구조물_돌쌓기(메).md | 6 + resources/help/B05/구조물_돌쌓기(찰).md | 6 + resources/help/B05/구조물_물넘이포장.md | 5 + resources/help/B05/구조물_배관.md | 5 + resources/help/B05/구조물_비탈면 녹화.md | 5 + resources/help/B05/구조물_세월교.md | 5 + resources/help/B05/구조물_옹벽.md | 6 + resources/help/B05/구조물_측구(옆도랑).md | 3 + resources/help/B05/구조물_큰돌쌓기.md | 6 + resources/help/B05/구조물_흙막이.md | 9 ++ resources/help/B06/00_설명서.md | 9 ++ resources/help/B06/표준_횡단면.md | 9 ++ resources/help/B07/00_설명서.md | 9 ++ resources/help/B08/00_설명서.md | 9 ++ resources/help/B09/00_설명서.md | 9 ++ resources/tester/test_54_2_help_files.py | 38 +++++ resources/tester/ui_checks/54-2_도움말.js | 114 ++++++++++++++ ui_template/ui_template_help.css | 84 ++++++++++ ui_template/ui_template_help.ts | 146 ++++++++++++++++++ ui_template/ui_template_locale_b1.ts | 40 ----- ui_template/ui_template_locale_b2.ts | 8 - ui_template/ui_template_overlay.ts | 2 + ui_template/ui_template_workflow_layout.ts | 9 +- 49 files changed, 622 insertions(+), 242 deletions(-) create mode 100644 common_util/common_util_help_router.py create mode 100644 resources/help/B02/00_설명서.md create mode 100644 resources/help/B03/00_설명서.md create mode 100644 resources/help/B03/10_필요한_파일.md create mode 100644 resources/help/B03/20_고르는_방법.md create mode 100644 resources/help/B03/30_알아_둘_것.md create mode 100644 resources/help/B04/00_설명서.md create mode 100644 resources/help/B05/00_설명서.md create mode 100644 resources/help/B05/구조물_BOX암거.md create mode 100644 resources/help/B05/구조물_기슭막이.md create mode 100644 resources/help/B05/구조물_돌쌓기(메).md create mode 100644 resources/help/B05/구조물_돌쌓기(찰).md create mode 100644 resources/help/B05/구조물_물넘이포장.md create mode 100644 resources/help/B05/구조물_배관.md create mode 100644 resources/help/B05/구조물_비탈면 녹화.md create mode 100644 resources/help/B05/구조물_세월교.md create mode 100644 resources/help/B05/구조물_옹벽.md create mode 100644 resources/help/B05/구조물_측구(옆도랑).md create mode 100644 resources/help/B05/구조물_큰돌쌓기.md create mode 100644 resources/help/B05/구조물_흙막이.md create mode 100644 resources/help/B06/00_설명서.md create mode 100644 resources/help/B06/표준_횡단면.md create mode 100644 resources/help/B07/00_설명서.md create mode 100644 resources/help/B08/00_설명서.md create mode 100644 resources/help/B09/00_설명서.md create mode 100644 resources/tester/test_54_2_help_files.py create mode 100644 resources/tester/ui_checks/54-2_도움말.js create mode 100644 ui_template/ui_template_help.css create mode 100644 ui_template/ui_template_help.ts diff --git a/A00_Common/design/design_dock.ts b/A00_Common/design/design_dock.ts index d330bf546..050c3c7db 100644 --- a/A00_Common/design/design_dock.ts +++ b/A00_Common/design/design_dock.ts @@ -65,6 +65,7 @@ export function createDesignDock( divider.className = "b05-structure__divider"; const row = document.createElement("div"); row.className = view === "profile" ? "b05-route__actions" : "b06-profile__actions"; + row.dataset.helpRow = ""; const press = (name: keyof DesignDockActions) => (): void => { void Promise.resolve(actions[name].run()); diff --git a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts index 68faf6897..eb8496792 100644 --- a/B02_ProjRegister/B02_ProjRegister_UI_Page.ts +++ b/B02_ProjRegister/B02_ProjRegister_UI_Page.ts @@ -20,6 +20,7 @@ import { showToast, } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; +import { createHelpButton } from "@ui/ui_template_help"; import { isBlank } from "@util/common_util_validate"; import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name"; @@ -299,7 +300,8 @@ export function renderB02ProjRegister(root: HTMLElement): void { // 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다. const actions = document.createElement("div"); actions.className = "ui-general-block__actions b02-proj__actions"; - actions.append(cancelBtn, submitBtn); + // 좌측 패널이 없어 [?](54-2)는 아래 단추 줄 맨 앞 + actions.append(createHelpButton(), cancelBtn, submitBtn); const card = createCard({ body: [grid, actions], raised: true }); const layout = createGeneralLayout({ pageClass: "b02-proj", diff --git a/B03_FileInput/B03_FileInput_UI_Guide.ts b/B03_FileInput/B03_FileInput_UI_Guide.ts index f0e408303..062f21290 100644 --- a/B03_FileInput/B03_FileInput_UI_Guide.ts +++ b/B03_FileInput/B03_FileInput_UI_Guide.ts @@ -1,77 +1,18 @@ /* ============================================================================= * B03_FileInput_UI_Guide.ts - * 파일 입력 좌측 안내 패널 (2026-09-03 사용자 지시). + * 파일 입력 좌측 패널 (2026-09-03 사용자 지시). * - * 종전에는 고르는 방법·필요한 파일 목록이 **선택 영역과 카드 안에** 길게 들어가 있어 - * 카드 한 장이 220px씩 차지했다. 안내는 한 번 읽으면 되는 것이라 다른 단계(B04·B05)와 - * 같은 자리 — 공용 오버레이의 좌측 패널(`createWorkflowOverlays.optionsContent`) — 로 - * 옮기고, 본문은 고르는 자리만 남긴다. - * - * 패널 양식은 **B04~B07과 같은 공용 양식**을 그대로 쓴다(2026-09-03 사용자 지시): - * 패널 루트 `{page}__form`, 문단은 `ui-collapsible ui-sidebar-section` + 제목에 - * `ui-collapsible__title`. 제목 행을 누르면 접히는 동작·캐럿·외곽선이 전부 공용 것이다. + * 안내 글(필요한 파일 · 고르는 방법 · 알아 둘 것)은 [?] 설명서 `resources/help/B03/*.md` 로 + * 옮김(54-2). 패널에는 현황 배너와 아래 단추 줄([?] 자리)만 남김. * ========================================================================== */ -import { attachCollapsible } from "@ui/ui_template_collapsible"; -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; - -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/** 안내 문단 하나 — 공용 접기 컨테이너(B06 `buildGroup`과 같은 조립). */ -function section( - titleKey: keyof typeof ui_locales, - itemKeys: (keyof typeof ui_locales)[], -): HTMLElement { - const group = document.createElement("section"); - group.className = "b03-file__guide-group ui-collapsible ui-sidebar-section"; - - const title = document.createElement("h3"); - title.className = "b03-file__guide-legend ui-collapsible__title"; - title.textContent = L(titleKey); - - const list = document.createElement("ul"); - list.className = "b03-file__guide-list"; - for (const key of itemKeys) { - const item = document.createElement("li"); - item.textContent = L(key); - list.append(item); - } - - group.append(title, list); - return group; -} - -/** - * 좌측 패널 본문 — 필요한 파일 · 고르는 방법 · 알아 둘 것. - * - * `statusNote`(재접속 현황 배너)는 「고르는 방법」 끝에 붙는다 — 프로젝트가 이미 완료라 - * 다시 올리면 교체된다는 안내라서, 고르기 전에 읽을 것들과 같은 자리에 둔다 - * (2026-09-03 사용자 지시). - */ +/** 좌측 패널 본문 — `statusNote`(재접속 현황 배너) + 아래 단추 줄(공용 [?] 가 붙음). */ export function createInputGuide(statusNote?: HTMLElement): HTMLElement { const guide = document.createElement("div"); guide.className = "b03-file__form"; - const howTo = section("B03_Guide_How_Title", [ - "B03_Guide_How_Drop", - "B03_Guide_How_Card", - "B03_Guide_How_Temp", - ]); - if (statusNote) howTo.append(statusNote); - guide.append( - section("B03_Guide_Files_Title", [ - "B03_Guide_Files_Route", - "B03_Guide_Files_Terrain", - "B03_Guide_Files_Optional", - ]), - howTo, - section("B03_Guide_Notes_Title", [ - "B03_Guide_Notes_Replace", - "B03_Guide_Notes_Crs", - "B03_Guide_Notes_LasFree", - ]), - ); - attachCollapsible(guide); + const actions = document.createElement("div"); + actions.className = "ui-sidebar-actions"; + if (statusNote) guide.append(statusNote); + guide.append(actions); return guide; } diff --git a/B03_FileInput/B03_FileInput_UI_Style.css b/B03_FileInput/B03_FileInput_UI_Style.css index 2f4b0d405..be437104d 100644 --- a/B03_FileInput/B03_FileInput_UI_Style.css +++ b/B03_FileInput/B03_FileInput_UI_Style.css @@ -310,10 +310,7 @@ } } -/* --- 좌측 안내 패널 (2026-09-03) — 본문에서 뺀 설명이 사는 자리. - 양식은 B04~B07 좌측 패널과 **같은 공용 양식**이다: 루트 `__form`, 문단은 - `ui-collapsible ui-sidebar-section`(외곽선·접힘·캐럿은 공용 CSS 몫), 제목은 - `__group-legend` 크기. 여기서는 값만 맞추고 새로 만들지 않는다. --- */ +/* --- 좌측 패널 — 현황 배너 + 아래 단추 줄(안내 글은 [?] 설명서로 옮김 · 54-2) --- */ .b03-file__form { display: flex; flex-direction: column; @@ -321,36 +318,6 @@ padding: var(--spacing-16); } -.b03-file__guide-group { - display: flex; - flex-direction: column; - gap: var(--spacing-8); - margin: 0; - padding: var(--spacing-16); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); -} - -.b03-file__guide-legend { - margin: 0; - padding: 0 var(--spacing-8); - font-size: var(--text-caption); - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); -} - -/* 글자 크기·색도 공용 양식 그대로 — B04 좌측 패널 본문(`b04-surface__check`)과 같은 - `--text-body-sm` · `--color-text-body`(2026-09-03 사용자 지시: 양식 = 글자 크기 포함). */ -.b03-file__guide-list { - margin: 0; - padding-left: var(--spacing-16); - display: flex; - flex-direction: column; - gap: var(--spacing-4); - font-size: var(--text-body-sm); - color: var(--color-text-body); -} - .b03-file__group { display: flex; flex-direction: column; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index c3ad245fe..a1279c3b3 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -373,16 +373,11 @@ export function createFacilityOptionsForm( // 옛 월류 폭 · 높이 · 바닥 경사 · 두께 · 길이 칸은 뺌(지난 정의 값) ── const paveForm = createSpecForm(specLinks, { onChange: () => emit() }); - // 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정). - const saveNote = document.createElement("p"); - saveNote.className = "b05-structure__owner-note"; - saveNote.textContent = - "[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀"; + // 10-A ⑲ 저장 규칙 글은 [?] 설명서 `구조물_<시설>.md` 로 옮김(54-2) — 병합은 `_Drainage_Facility_Merge`. // 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) — // 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다. root.append( - saveNote, pipeForm.root, fordForm.root, paveForm.root, diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts index c001a7137..42654c90d 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts @@ -9,7 +9,6 @@ * ========================================================================== */ import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend"; -import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json"; /* ── 유입구 구조 ──────────────────────────────────────────────────────────── */ @@ -245,39 +244,18 @@ export interface RevetSideGroup { slot: HTMLElement; } -/** 독립 기슭막이 치수 근거 줄 — `revetment_sabang_*.json` 한 벌(B08 사유와 같은 파일 · 쪽·줄 번호). - * ⚠ 파일 판이 바뀌면 이 import 도 옮길 것 — 시험(`test_b05_revetment_sabang_basis`)이 최신 판을 대조. */ -function sabangBasisLines(): string[] { - const { items, scope } = revetmentSabang; - const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`; - return [ - `높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`, - `계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`, - `둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`, - `뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`, - scope, - ]; -} - export function createRevetSideGroup(keys: RevetmentKeys): RevetSideGroup { const box = group("기슭막이"); const kind = document.createElement("select"); kind.replaceChildren(new Option("기슭막이", "기슭막이")); // 높이는 기본값 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). const fields = createRevetmentFields(keys, { height: "" }); - const basis = document.createElement("p"); - basis.className = "b05-route__note"; - basis.replaceChildren( - ...sabangBasisLines().flatMap((line, index) => - index ? [document.createElement("br"), line] : [line], - ), - ); + // 치수 근거 글(사방기술교본)은 [?] 설명서 `구조물_기슭막이.md` 로 옮김(54-2) const slot = document.createElement("div"); slot.className = "b05-structure__adjust-slot"; box.body.append( grid(labeled("구조", kind), fields.formField), ...fields.rows, - basis, // 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과 // 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자). slot, diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index eaf354609..3ea90331b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -9,23 +9,13 @@ import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station"; -export function field( - labelText: string, - input: HTMLElement, - basis?: string | null, -): HTMLLabelElement { +export function field(labelText: string, input: HTMLElement): HTMLLabelElement { const wrapper = document.createElement("label"); wrapper.className = "b05-route__field"; const caption = document.createElement("span"); caption.textContent = labelText; + // 칸 밑 근거 글(`default_basis`)은 [?] 설명서 `구조물_<종류>.md` 로 옮김(54-2) wrapper.append(caption, input); - // 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정). - if (basis) { - const note = document.createElement("small"); - note.className = "b05-structure__basis"; - note.textContent = basis; - wrapper.append(note); - } return wrapper; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Form.ts b/B05_Profile/B05_Profile_UI_Structures_Form.ts index d2d4889bb..7aa35eb32 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Form.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Form.ts @@ -35,8 +35,6 @@ export interface StructuresFormElements { body: HTMLElement; groupSelect: HTMLSelectElement; typeSelect: HTMLSelectElement; - /** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */ - ownerNote: HTMLElement; startFields: StationFields; anchorFields: StationFields; endFields: StationFields; @@ -113,13 +111,6 @@ export function buildStructuresForm(options: { typeRow.className = "b05-structure__grid"; typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect)); - // 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라 - // (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자: - // 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다. - const ownerNote = document.createElement("p"); - ownerNote.className = "b05-structure__owner-note"; - ownerNote.hidden = true; - // 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다. const optionRow = document.createElement("div"); optionRow.className = "b05-structure__grid"; @@ -178,7 +169,6 @@ export function buildStructuresForm(options: { body.append( typeRow, - ownerNote, positionRow, summary.root, // 종류 단위 「구조물 조합」 잇기 — 기준 측점 바로 아래(PLAN 52-1 · 컨테이너 없는 종류) positionDivider, @@ -195,7 +185,6 @@ export function buildStructuresForm(options: { body, groupSelect, typeSelect, - ownerNote, startFields, anchorFields, endFields, diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 160806a93..2dd7f201e 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -78,7 +78,6 @@ export function createStructuresSection( body, groupSelect, typeSelect, - ownerNote, startFields, anchorFields, endFields, @@ -233,12 +232,8 @@ export function createStructuresSection( startFields.wrap.hidden = !isInterval; endFields.wrap.hidden = !isInterval; primary.disabled = !type; - // 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면 - // 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자). - ownerNote.textContent = type?.design_owner - ? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.` - : ""; - ownerNote.hidden = !type?.design_owner; + // [?] 설명서가 먼저 여는 항목 = `구조물_<종류 이름>.md`(54-2 · 종류 안내 글은 그 파일로 옮김) + root.dataset.helpTopic = type ? `구조물_${type.name}` : ""; // 정의가 기준 측점을 끈 종류(48-3) — 기준 측점이 곧 위치인 종류(점형 · 범위 계산)는 늘 보임 anchorFields.wrap.hidden = isInterval && !showsAnchor(type!); anchorFields.wrap.querySelector("span")!.textContent = isInterval diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 320f151ef..7c9b09543 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -101,11 +101,7 @@ export function renderOptionFields( // 정의 그룹 상자로 옮겨 갈 숫자 칸 = 명세 컨테이너와 같은 [-][숫자][+](52-18) const grouped = option.input === "number" && !!type.groups?.some((g) => g.options.includes(option.key)); - const wrap = field( - label, - grouped ? stepper(input as HTMLInputElement, 0.1) : input, - option.default_basis, - ); + const wrap = field(label, grouped ? stepper(input as HTMLInputElement, 0.1) : input); if (grouped) wrap.htmlFor = input.id; // 라벨 대상 = 숫자 칸(첫 버튼으로 새지 않게) wrap.dataset.optionKeys = option.key; // 옵션 그룹 상자가 이 표식으로 칸을 옮김(PLAN 46-3) // 배치 격자(48-3) — 정의 `배치` 의 줄 · 칸 · 너비 · 없으면 흐름대로 diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index ac4c69368..65ba8eb49 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -215,13 +215,6 @@ line-height: 1.5; } -/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */ -.b05-structure__basis { - color: var(--color-text-muted, #9aa1ad); - font-size: var(--text-caption, 12px); - line-height: 1.3; -} - /* 시작·기준·종료 측점 = 3행. 한 행은 [라벨][측점][+거리] 가로 배치 * (2026-08-17 사용자 지시 2). */ .b05-structure__position-row { diff --git a/B06_Section/B06_Section_UI_Standard_Diagram.ts b/B06_Section/B06_Section_UI_Standard_Diagram.ts index e6c37a58b..4c409b174 100644 --- a/B06_Section/B06_Section_UI_Standard_Diagram.ts +++ b/B06_Section/B06_Section_UI_Standard_Diagram.ts @@ -118,10 +118,7 @@ export function buildStandardDiagram(): HTMLElement { text(252, 196, L("B06_Std_Diagram_Fill"), "b06-diag__label-sm", "middle"), ); + // 모식도 설명 글은 [?] 설명서 `B06/표준_횡단면.md` 로 옮김(54-2) wrap.append(svg); - const caption = document.createElement("p"); - caption.className = "b06-std__diagram-caption"; - caption.textContent = L("B06_Std_Diagram_Caption"); - wrap.append(caption); return wrap; } diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index b564cd922..71c09b3ae 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -276,6 +276,7 @@ export function createStandardPanel( const root = document.createElement("div"); root.className = "b06-std"; + root.dataset.helpTopic = "표준_횡단면"; // [?] 설명서 항목(54-2 · 이 상자 안내 글은 그 파일로 옮김) // 변수 위치 안내 모식도(고정 도형): 각 설정값이 횡단면 어느 위치인지 표시(작업 C-5). root.append(buildStandardDiagram()); @@ -367,9 +368,6 @@ export function createStandardPanel( const legend = document.createElement("summary"); legend.className = "b06-std__legend"; legend.textContent = L("B06_Std_Detail_Title"); - const note = document.createElement("p"); - note.className = "b06-std__note"; - note.textContent = L("B06_Std_LType_Note"); fieldset.append( legend, buildScope("common"), @@ -377,7 +375,6 @@ export function createStandardPanel( buildScope("rock"), buildDivider("B06_Std_Section_PavedOnly"), buildScope("paved"), - note, ); return fieldset; }; diff --git a/B06_Section/B06_Section_UI_Style.css b/B06_Section/B06_Section_UI_Style.css index 89fed6003..c4d3492ec 100644 --- a/B06_Section/B06_Section_UI_Style.css +++ b/B06_Section/B06_Section_UI_Style.css @@ -173,12 +173,6 @@ margin-top: var(--spacing-8); } -.b06-std__diagram-caption { - margin: var(--spacing-8) 0 0; - font-size: var(--text-caption); - color: var(--color-text-muted); -} - /* ─── 다른 프로젝트에서 불러오기 (작업 C-6) ───────────────────────── */ .b06-std__loader { display: flex; @@ -307,12 +301,6 @@ color: var(--color-text-secondary); } -.b06-std__note { - margin: 0; - font-size: var(--text-caption); - color: var(--color-text-muted); -} - .b06-std__actions { display: flex; gap: var(--spacing-8); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts index e40721b11..5cd7b9bca 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Page.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Page.ts @@ -430,6 +430,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise { // (2026-09-02 사용자 지시 — CAD 리본과 겹치던 떠 있는 띠를 여기로 옮김). const confirmButtonRow = document.createElement("div"); confirmButtonRow.className = "b07-drawing-actions__row"; + confirmButtonRow.dataset.helpRow = ""; confirmButtonRow.append(frameEditor.button, confirmButton); confirmActions.append(frameEditor.banner, confirmButtonRow); diff --git a/common_util/common_util_help_router.py b/common_util/common_util_help_router.py new file mode 100644 index 000000000..1f9c4be92 --- /dev/null +++ b/common_util/common_util_help_router.py @@ -0,0 +1,34 @@ +"""[?] 설명서 글(54-2) — `resources/help/<페이지>/*.md` 를 부를 때마다 읽어 줌. + +사용자가 메모장으로 파일을 고치면 화면 고침 없이 다음에 열 때 바뀐 글이 보임. +파일 이름 순서 = 모달 왼쪽 차례 순서 · 제목 = 첫 `# ` 줄(없으면 파일 이름). +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api/help", tags=["help"]) + +HELP_ROOT = Path(__file__).resolve().parent.parent / "resources" / "help" +PAGE_RE = re.compile(r"^B0[2-9]$") + + +def read_help(page: str, root: Path = HELP_ROOT) -> list[dict[str, str]]: + if not PAGE_RE.match(page): + raise HTTPException(status_code=404, detail="없는 페이지") + folder = root / page + items = [] + for path in sorted(folder.glob("*.md")) if folder.is_dir() else []: + text = path.read_text(encoding="utf-8-sig") + head = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), "") + items.append({"key": path.stem, "title": head or path.stem, "text": text}) + return items + + +@router.get("/{page}") +async def get_help(page: str) -> dict[str, list[dict[str, str]]]: + return {"items": read_help(page)} diff --git a/main.py b/main.py index 13dd66cd9..3a2c31b48 100644 --- a/main.py +++ b/main.py @@ -81,6 +81,7 @@ from common_util.common_util_auth import ( # 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가 # 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). from common_util.common_util_dev_unlock_router import router as dev_unlock_router +from common_util.common_util_help_router import router as help_router from common_util.common_util_resource_monitor import sample_resources_loop from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool @@ -661,6 +662,8 @@ app.include_router(m02_project_items_router, dependencies=protected) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) +# [?] 설명서 글(54-2) — 로그인만 · 파일을 부를 때마다 읽음. +app.include_router(help_router, dependencies=protected) # ───────────────────────────────────────────────────────────────────────── diff --git a/resources/help/B02/00_설명서.md b/resources/help/B02/00_설명서.md new file mode 100644 index 000000000..ce3f51ea9 --- /dev/null +++ b/resources/help/B02/00_설명서.md @@ -0,0 +1,9 @@ +# 프로젝트 등록 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B03/00_설명서.md b/resources/help/B03/00_설명서.md new file mode 100644 index 000000000..bf1fc7923 --- /dev/null +++ b/resources/help/B03/00_설명서.md @@ -0,0 +1,9 @@ +# 파일입력 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B03/10_필요한_파일.md b/resources/help/B03/10_필요한_파일.md new file mode 100644 index 000000000..4fe4b1b22 --- /dev/null +++ b/resources/help/B03/10_필요한_파일.md @@ -0,0 +1,5 @@ +# 필요한 파일 + +- 계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개. +- 지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW. +- 지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다. diff --git a/resources/help/B03/20_고르는_방법.md b/resources/help/B03/20_고르는_방법.md new file mode 100644 index 000000000..02333ba83 --- /dev/null +++ b/resources/help/B03/20_고르는_방법.md @@ -0,0 +1,5 @@ +# 고르는 방법 + +- 여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다. +- 카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다. +- [임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다. diff --git a/resources/help/B03/30_알아_둘_것.md b/resources/help/B03/30_알아_둘_것.md new file mode 100644 index 000000000..e80291a60 --- /dev/null +++ b/resources/help/B03/30_알아_둘_것.md @@ -0,0 +1,5 @@ +# 알아 둘 것 + +- 이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다. +- 작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다. +- 라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다. diff --git a/resources/help/B04/00_설명서.md b/resources/help/B04/00_설명서.md new file mode 100644 index 000000000..bcb9a433f --- /dev/null +++ b/resources/help/B04/00_설명서.md @@ -0,0 +1,9 @@ +# 전처리 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B05/00_설명서.md b/resources/help/B05/00_설명서.md new file mode 100644 index 000000000..52d74d9b4 --- /dev/null +++ b/resources/help/B05/00_설명서.md @@ -0,0 +1,9 @@ +# 종단설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B05/구조물_BOX암거.md b/resources/help/B05/구조물_BOX암거.md new file mode 100644 index 000000000..b6993e1e3 --- /dev/null +++ b/resources/help/B05/구조물_BOX암거.md @@ -0,0 +1,5 @@ +# 구조물 · BOX암거 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_기슭막이.md b/resources/help/B05/구조물_기슭막이.md new file mode 100644 index 000000000..de7432ce8 --- /dev/null +++ b/resources/help/B05/구조물_기슭막이.md @@ -0,0 +1,13 @@ +# 구조물 · 기슭막이 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 + +## 치수 근거 + +- 높이 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 설계자 입력 · 비우면 미확정(금액에 안 들어감) +- 계획비탈 1:0.3~0.5(사방기술교본 2-나:129 · 3-가:151) — 제안값은 품셈 표준경사 +- 둑마루 두께 0.3~0.5m(사방기술교본 2-나:143 · 3-가:201 · 3-가:219(콘크리트기슭막이) · 2-나:135(콘크리트 0.3m)) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식 +- 뒷채움 조약돌 두께 — 사방기술교본 2-나:133(「사방품셈 규정에 의하여」) → 품셈 13-4-4 [주]⑨ 원문 L7164~7171 +- ⚠ 사방(계류) 교본 기준 — 임도 배수관 유입·유출부 적용은 확인 남음 diff --git a/resources/help/B05/구조물_돌쌓기(메).md b/resources/help/B05/구조물_돌쌓기(메).md new file mode 100644 index 000000000..1f447c41f --- /dev/null +++ b/resources/help/B05/구조물_돌쌓기(메).md @@ -0,0 +1,6 @@ +# 구조물 · 돌쌓기(메) + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_돌쌓기(찰).md b/resources/help/B05/구조물_돌쌓기(찰).md new file mode 100644 index 000000000..3fcd36e38 --- /dev/null +++ b/resources/help/B05/구조물_돌쌓기(찰).md @@ -0,0 +1,6 @@ +# 구조물 · 돌쌓기(찰) + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_물넘이포장.md b/resources/help/B05/구조물_물넘이포장.md new file mode 100644 index 000000000..048d76751 --- /dev/null +++ b/resources/help/B05/구조물_물넘이포장.md @@ -0,0 +1,5 @@ +# 구조물 · 물넘이포장 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_배관.md b/resources/help/B05/구조물_배관.md new file mode 100644 index 000000000..53872704d --- /dev/null +++ b/resources/help/B05/구조물_배관.md @@ -0,0 +1,5 @@ +# 구조물 · 배관 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_비탈면 녹화.md b/resources/help/B05/구조물_비탈면 녹화.md new file mode 100644 index 000000000..07695f068 --- /dev/null +++ b/resources/help/B05/구조물_비탈면 녹화.md @@ -0,0 +1,5 @@ +# 구조물 · 비탈면 녹화 + +## 칸 기본값의 뜻 + +- **공법**(횡단설계) — 「초류종자 살포」는 여기 없음 — 토공집계에서 사면 전체로 섬(품셈 5-24 · 두 곳에 두면 이중계상 · 2026-09-15 브레인) diff --git a/resources/help/B05/구조물_세월교.md b/resources/help/B05/구조물_세월교.md new file mode 100644 index 000000000..f3c8824ee --- /dev/null +++ b/resources/help/B05/구조물_세월교.md @@ -0,0 +1,5 @@ +# 구조물 · 세월교 + +## 저장 + +- [저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀 diff --git a/resources/help/B05/구조물_옹벽.md b/resources/help/B05/구조물_옹벽.md new file mode 100644 index 000000000..d8ffe2a85 --- /dev/null +++ b/resources/help/B05/구조물_옹벽.md @@ -0,0 +1,6 @@ +# 구조물 · 옹벽 + +## 칸 기본값의 뜻 + +- **높이** — 기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음 +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 diff --git a/resources/help/B05/구조물_측구(옆도랑).md b/resources/help/B05/구조물_측구(옆도랑).md new file mode 100644 index 000000000..9d3736fe3 --- /dev/null +++ b/resources/help/B05/구조물_측구(옆도랑).md @@ -0,0 +1,3 @@ +# 구조물 · 측구(옆도랑) + +- 횡단 설계에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다. diff --git a/resources/help/B05/구조물_큰돌쌓기.md b/resources/help/B05/구조물_큰돌쌓기.md new file mode 100644 index 000000000..05311185e --- /dev/null +++ b/resources/help/B05/구조물_큰돌쌓기.md @@ -0,0 +1,6 @@ +# 구조물 · 큰돌쌓기 + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **버림 콘크리트**(횡단설계) — 사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜ diff --git a/resources/help/B05/구조물_흙막이.md b/resources/help/B05/구조물_흙막이.md new file mode 100644 index 000000000..5fc4f56de --- /dev/null +++ b/resources/help/B05/구조물_흙막이.md @@ -0,0 +1,9 @@ +# 구조물 · 흙막이 + +## 칸 기본값의 뜻 + +- **설치 측** — 「자동(성토 쪽)」 — 칸이 생기기 전의 동작을 이어받는 표식 +- **단 수(다단)** — 1 = 다단 없음 — 도메인 수치가 아니라 「단이 하나」라는 표식(기슭막이와 같은 벌) +- **기준 올림(사면 위로)** — 0 = 자동 자리 그대로 — 올리지 않음을 뜻함(기슭막이와 같은 벌) +- **기준 좌우 이동** — 0 = 자동 자리 그대로 — 옮기지 않음을 뜻함(기슭막이와 같은 벌) +- **조달**(횡단설계) — 「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음 diff --git a/resources/help/B06/00_설명서.md b/resources/help/B06/00_설명서.md new file mode 100644 index 000000000..0308a208e --- /dev/null +++ b/resources/help/B06/00_설명서.md @@ -0,0 +1,9 @@ +# 횡단설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B06/표준_횡단면.md b/resources/help/B06/표준_횡단면.md new file mode 100644 index 000000000..2ac92b0ae --- /dev/null +++ b/resources/help/B06/표준_횡단면.md @@ -0,0 +1,9 @@ +# 표준 횡단면 설정 + +## 변수 위치 안내 + +- 표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님). + +## 표준횡단면 상세값 + +- L형 측구는 각 횡단면도에서 선택합니다. diff --git a/resources/help/B07/00_설명서.md b/resources/help/B07/00_설명서.md new file mode 100644 index 000000000..6fc3ddd3c --- /dev/null +++ b/resources/help/B07/00_설명서.md @@ -0,0 +1,9 @@ +# 상세설계 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B08/00_설명서.md b/resources/help/B08/00_설명서.md new file mode 100644 index 000000000..291ac50b7 --- /dev/null +++ b/resources/help/B08/00_설명서.md @@ -0,0 +1,9 @@ +# 수량산출 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/help/B09/00_설명서.md b/resources/help/B09/00_설명서.md new file mode 100644 index 000000000..4297e096b --- /dev/null +++ b/resources/help/B09/00_설명서.md @@ -0,0 +1,9 @@ +# 원가계산 설명서 + +## 이 화면에서 하는 일 + +## 작업 순서 + +## 단추 + +## 주의할 점 diff --git a/resources/tester/test_54_2_help_files.py b/resources/tester/test_54_2_help_files.py new file mode 100644 index 000000000..480fda24a --- /dev/null +++ b/resources/tester/test_54_2_help_files.py @@ -0,0 +1,38 @@ +"""54-2 [?] 설명서 — 서버가 resources/help/<페이지>/*.md 를 부를 때마다 읽음(고치면 바로 바뀜).""" + +import tempfile +from pathlib import Path + +import pytest +from fastapi import HTTPException + +from common_util.common_util_help_router import HELP_ROOT, read_help + + +def test_every_page_has_manual_first(): + for n in range(2, 10): + items = read_help(f"B0{n}") + assert items and items[0]["key"] == "00_설명서" + + +def test_title_order_and_live_edit(): + tmp_path = Path(tempfile.mkdtemp()) + page = tmp_path / "B05" + page.mkdir() + (page / "10_배관.md").write_text("# 배관\n\n본문", encoding="utf-8") + (page / "00_설명서.md").write_text("제목 없음", encoding="utf-8") + items = read_help("B05", tmp_path) + assert [i["key"] for i in items] == ["00_설명서", "10_배관"] + assert [i["title"] for i in items] == ["00_설명서", "배관"] + (page / "10_배관.md").write_text("# 배관\n\n고친 글", encoding="utf-8") + assert "고친 글" in read_help("B05", tmp_path)[1]["text"] + + +@pytest.mark.parametrize("page", ["B01", "B10", "../B05", "b05"]) +def test_bad_page_refused(page): + with pytest.raises(HTTPException): + read_help(page) + + +def test_root_is_repo_resources(): + assert HELP_ROOT.parts[-2:] == ("resources", "help") diff --git a/resources/tester/ui_checks/54-2_도움말.js b/resources/tester/ui_checks/54-2_도움말.js new file mode 100644 index 000000000..f3592c5b0 --- /dev/null +++ b/resources/tester/ui_checks/54-2_도움말.js @@ -0,0 +1,114 @@ +/* 54-2 — [?] 설명서: B02~B09 좌측 패널 아래 단추 묶음 맨 왼쪽 · 모달 = 왼쪽 차례 · 오른쪽 파일 글 · + * 구조물 종류를 고르면 그 항목 먼저 · 패널 안 설명 글 0 + * 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트) + * 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌) · 1분 넘으면 끊기므로 + * `window.__help542 = ["b02-proj-register", …];` 를 앞에 붙여 네 화면씩 두 번 + * 쓰는 것: 없음(주소 이동 · 누르기 · 모달 닫기 · 끝나면 처음 주소로) + * 파일 고침 반영: 셸이 `resources/help/B04/99_임시.md` 를 만들고(글 A) 부름 → 글 B 로 고치고 다시 부름 → + * `temp` 가 A → B 로 바뀌면 통과 · 뒤에 파일 지움(없으면 temp = null) + * 기대 수치: + * - pages 8 · 줄마다 btn = 1 · first = true(묶음 맨 왼쪽) · inLeft = true(B02 는 아래 단추 줄) · + * modal = true · toc[0] = 「… 설명서」 · text = 그 설명서 제목 글 + * - B03 toc = 설명서 + 필요한 파일 · 고르는 방법 · 알아 둘 것 · B06 toc 에 「표준 횡단면 설정」 + * - pipeTopic = 「구조물 · 배관」(B05 에서 배관을 고른 채 [?]) · leftovers = 0(옮긴 글이 패널에 없음) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 15000) => { + for (let t = 0; t < ms; t += 200) { + const v = fn(); + if (v) return v; + await w(200); + } + return null; + }; + const MOVED = [ + "[저장]은 이 폼에 있는 칸만", + "에서 관리 — 여기서 넣어도", + "사방기술교본", + "표준 편절편성 단면 모식도", + "L형 측구는 각 횡단면도에서", + "끌어 놓으면 확장자로", + "shapefile 한 벌", + "작업 좌표계는 라이다 PRJ", + "칸이 생기기 전의 동작", + "소광리 도면 H=2.0", + ]; + const start = location.hash; + const vis = (n) => n && n.offsetParent !== null; + const leftText = () => + document.querySelector(".ui-workflow-overlay__panel--title")?.innerText ?? ""; + const openModal = async (btn) => { + btn.click(); + const box = await until(() => document.querySelector(".ui-help .ui-help__text h2")); + const res = { + modal: !!box, + toc: [...document.querySelectorAll(".ui-help__link")].map((b) => b.innerText.trim()), + text: box?.innerText ?? null, + active: document.querySelector(".ui-help__text")?.dataset.helpKey ?? null, + }; + const temp = [...document.querySelectorAll(".ui-help__link")].find((b) => + b.dataset.helpKey?.startsWith("99_"), + ); + if (temp) { + temp.click(); + res.temp = document.querySelector(".ui-help__text p")?.innerText ?? null; + } + document.querySelector(".ui-help .ui-modal__top button")?.click(); + await w(200); + res.closed = !document.querySelector(".ui-help"); + return res; + }; + const out = { pages: [] }; + // orca eval 은 한 번에 1분쯤에서 끊김 — 앞에 `window.__help542 = ["b05-profile", …];` 를 붙이면 그 화면만 + const routes = window.__help542 ?? [ + "b02-proj-register", + "b03-file-input", + "b04-preprocess", + "b05-profile", + "b06-section", + "b07-design-detail", + "b08-quantity", + "b09-estimation", + ]; + let prev = null; + for (const route of routes) { + location.hash = `#/${route}`; + await w(3000); // 불러오는 동안의 임시 틀(B05 등)이 실제 화면으로 바뀔 때까지 + // 앞 화면 단추가 빠지고 이 화면 단추가 설 때까지 + const btn = await until(() => + [...document.querySelectorAll(".ui-help-btn")].find((b) => vis(b) && b !== prev), + ); + prev = btn; + await w(1500); + const row = btn?.parentElement; + const res = { + page: route.slice(0, 3).toUpperCase(), + btn: document.querySelectorAll(".ui-help-btn").length, + first: !!row && row.firstElementChild === btn, + inLeft: !!btn?.closest(".ui-sidebar-actions") || !!btn?.closest(".b02-proj__actions"), + leftovers: MOVED.filter((s) => leftText().includes(s)), + }; + if (btn) Object.assign(res, await openModal(btn)); + if (route === "b05-profile") { + // 배관 하나를 골라 [?] — 구조물 종류 항목이 먼저 열림(이미 골라져 있으면 그대로 · 끝나면 처음 상태로) + const picked = () => document.querySelector('[data-help-topic="구조물_배관"]'); + const was = !!picked(); + const item = await until(() => + [...document.querySelectorAll(".b05-route__irregular-item")].find((li) => + li.innerText.includes("배관"), + ), + ); + if (!was) item?.click(); + await until(picked, 8000); + await w(800); + res.leftoversPipe = MOVED.filter((s) => leftText().includes(s)); + const again = await openModal([...document.querySelectorAll(".ui-help-btn")].find(vis)); + res.pipeTopic = again.text; + if (!was) item?.click(); // 고름 풂 + } + out.pages.push(res); + } + location.hash = start; + return JSON.stringify(out); +})(); diff --git a/ui_template/ui_template_help.css b/ui_template/ui_template_help.css new file mode 100644 index 000000000..7c9f3f69e --- /dev/null +++ b/ui_template/ui_template_help.css @@ -0,0 +1,84 @@ +/* [?] 설명서(54-2) — 단추는 글자 폭만 · 모달은 왼쪽 차례 · 오른쪽 글 */ +.ui-sidebar-actions .ui-help-btn, +.ui-help-btn { + flex: none; + width: auto; + min-width: 2.25rem; + padding-inline: var(--spacing-12); +} + +.ui-help.ui-modal { + width: min(960px, 92vw); + height: var(--modal-max, 80vh); +} + +.ui-help .ui-modal__body { + flex: 1 1 auto; + min-height: 0; +} + +.ui-help__grid { + display: grid; + grid-template-columns: 200px 1fr; + gap: var(--spacing-16); + height: 100%; + min-height: 0; +} + +.ui-help__toc { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-right: var(--spacing-8); + border-right: 1px solid var(--color-border); + overflow-y: auto; +} + +.ui-help__link { + padding: var(--spacing-4) var(--spacing-8); + border: none; + border-radius: var(--radius-sm); + background: none; + color: var(--color-text-secondary); + font: inherit; + text-align: left; + cursor: pointer; +} + +.ui-help__link:hover, +.ui-help__link.is-active { + background: color-mix(in srgb, var(--color-border) 60%, transparent); + color: var(--color-text-body); +} + +.ui-help__text { + overflow-y: auto; + line-height: 1.6; +} + +.ui-help__text h2 { + margin: 0 0 var(--spacing-12); + font-size: 1.4em; +} + +.ui-help__text h3 { + margin: var(--spacing-16) 0 var(--spacing-4); + font-size: 1.1em; +} + +.ui-help__text h4, +.ui-help__text h5 { + margin: var(--spacing-12) 0 var(--spacing-4); + font-size: 1em; +} + +.ui-help__text p, +.ui-help__text ul, +.ui-help__text ol { + margin: 0 0 var(--spacing-8); +} + +.ui-help__text ul, +.ui-help__text ol { + padding-left: 1.4em; +} diff --git a/ui_template/ui_template_help.ts b/ui_template/ui_template_help.ts new file mode 100644 index 000000000..712310b81 --- /dev/null +++ b/ui_template/ui_template_help.ts @@ -0,0 +1,146 @@ +/* ============================================================================= + * ui_template_help.ts + * [?] 설명서(54-2) — 좌측 패널 아래 단추 묶음 맨 왼쪽 단추 + 모달(왼쪽 차례 · 오른쪽 글). B02~B09 공용. + * 글 = `resources/help/<페이지>/*.md` — 서버가 열 때마다 읽음(파일만 고치면 다음에 열 때 바뀜). + * 페이지 = 주소(#/b05-…)의 B0N · 먼저 여는 항목 = 좌측 패널에서 보이는 `[data-help-topic]` 중 마지막 + * (지금 연 상자 · 구조물 종류) · 없으면 첫 파일(00_설명서). + * 단추 자리 = `.ui-sidebar-actions` 안 `[data-help-row]`(없으면 그 줄 자신)의 맨 앞. + * ========================================================================== */ + +import "./ui_template_help.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { createButton, el } from "./ui_template_elements"; +import { openModal } from "./ui_template_modal"; + +interface HelpItem { + key: string; + title: string; + text: string; +} + +const TEXT = { button: "?", tip: "설명서", title: "설명서", close: "닫기", empty: "설명 글 없음" }; + +function currentPage(): string { + const n = /^#\/b0(\d)/i.exec(location.hash)?.[1]; + return n ? `B0${n}` : ""; +} + +function escapeHtml(text: string): string { + return text.replace( + /[&<>"]/g, + (c) => `&${{ "&": "amp", "<": "lt", ">": "gt", '"': "quot" }[c]};`, + ); +} + +function inline(text: string): string { + return escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/`(.+?)`/g, "$1"); +} + +/** 메모장 글에 쓸 만큼만 — 제목(#) · 목록(- · 1.) · 문단 · **굵게** · `코드` */ +export function renderHelpMarkdown(text: string): string { + const out: string[] = []; + let list: "ul" | "ol" | null = null; + let para: string[] = []; + const flush = (): void => { + if (para.length) out.push(`

    ${para.map(inline).join("
    ")}

    `); + para = []; + if (list) out.push(``); + list = null; + }; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + const head = /^(#{1,4})\s+(.*)$/.exec(line); + const item = /^(?:([-*])|\d+\.)\s+(.*)$/.exec(line); + if (!line) flush(); + else if (head) { + flush(); + out.push(`${inline(head[2])}`); + } else if (item) { + const kind = item[1] ? "ul" : "ol"; + if (para.length || list !== kind) flush(); + if (!list) out.push(`<${kind}>`); + list = kind; + out.push(`
  • ${inline(item[2])}
  • `); + } else { + if (list) flush(); + para.push(line); + } + } + flush(); + return out.join(""); +} + +function visibleTopics(): string[] { + const panel = document.querySelector(".ui-workflow-overlay__panel--title") ?? document; + return [...panel.querySelectorAll("[data-help-topic]")] + .filter((node) => node.offsetParent !== null) + .map((node) => node.dataset.helpTopic ?? ""); +} + +async function fetchItems(page: string): Promise { + const response = await fetch(`${API_BASE_URL}/help/${page}`, { + credentials: "include", + cache: "no-store", + }); + return response.ok ? ((await response.json()) as { items: HelpItem[] }).items : []; +} + +/** B05 · B06 = 한 페이지(구조물 폼 공유) — 제 폴더 뒤에 짝 폴더 항목(짝의 설명서는 뺌)도 보임 */ +const PAIR: Record = { B05: "B06", B06: "B05" }; + +async function openHelp(): Promise { + const page = currentPage(); + const pair = PAIR[page]; + const [own, other] = await Promise.all([fetchItems(page), pair ? fetchItems(pair) : []]); + const items = [...own, ...other.filter((item) => !own.some((o) => o.key === item.key))]; + const topics = visibleTopics(); + const first = [...topics].reverse().find((key) => items.some((item) => item.key === key)); + openModal({ + title: `${TEXT.title} · ${page}`, + closeLabel: TEXT.close, + dialogClass: "ui-help", + mount: (body) => { + const article = el("article", { className: "ui-help__text" }); + const toc = el("nav", { className: "ui-help__toc" }); + const show = (key: string): void => { + const item = items.find((entry) => entry.key === key); + article.innerHTML = item ? renderHelpMarkdown(item.text) : `

    ${TEXT.empty}

    `; + article.dataset.helpKey = key; + toc + .querySelectorAll("button") + .forEach((b) => b.classList.toggle("is-active", b.dataset.helpKey === key)); + }; + for (const item of items) { + const link = el("button", { + className: "ui-help__link", + text: item.title, + attrs: { type: "button" }, + }); + link.dataset.helpKey = item.key; + link.addEventListener("click", () => show(item.key)); + toc.append(link); + } + body.append(el("div", { className: "ui-help__grid", children: [toc, article] })); + show(first ?? items[0]?.key ?? ""); + }, + }); +} + +export function createHelpButton(): HTMLButtonElement { + const button = createButton({ + label: TEXT.button, + variant: "ghost", + onClick: () => void openHelp(), + }); + button.classList.add("ui-help-btn"); + button.title = button.ariaLabel = TEXT.tip; + return button; +} + +/** 아래 단추 묶음에 [?] 를 한 번 — `[data-help-row]` 가 있으면 그 줄 · 없으면 묶음 자신의 맨 앞 */ +export function placeHelpButton(actions: HTMLElement): void { + if (actions.querySelector(".ui-help-btn")) return; + (actions.querySelector("[data-help-row]") ?? actions).prepend(createHelpButton()); +} diff --git a/ui_template/ui_template_locale_b1.ts b/ui_template/ui_template_locale_b1.ts index 69fe4d017..ddf13ae38 100644 --- a/ui_template/ui_template_locale_b1.ts +++ b/ui_template/ui_template_locale_b1.ts @@ -228,46 +228,6 @@ export const ui_locales_b1 = { "Upload the required planned route, terrain, and point cloud files.", ], B03_File_Select_Label: ["입력 파일 선택", "Select input files"], - /* --- 좌측 안내 패널 (2026-09-03) --- */ - B03_Guide_Files_Title: ["필요한 파일", "Files you need"], - B03_Guide_Files_Route: [ - "계획노선 — shapefile 한 벌(.shp·.shx·.dbf·.cpg·.prj) 5개.", - "Planned route — the five shapefile parts (.shp, .shx, .dbf, .cpg, .prj).", - ], - B03_Guide_Files_Terrain: [ - "지형 — 포인트클라우드 LAS/LAZ 1개와 좌표계 PRJ, 래스터 기준 TFW.", - "Terrain — one LAS/LAZ point cloud plus the PRJ projection and TFW world file.", - ], - B03_Guide_Files_Optional: [ - "지형 TIF(DEM)는 선택 사항 — 없어도 지표면 분석이 진행됩니다.", - "The terrain TIF (DEM) is optional — surface analysis runs without it.", - ], - B03_Guide_How_Title: ["고르는 방법", "How to pick"], - B03_Guide_How_Drop: [ - "여러 개를 한 번에 끌어 놓으면 확장자로 카드에 자동 배정됩니다.", - "Drop several files at once — each is assigned to a card by its extension.", - ], - B03_Guide_How_Card: [ - "카드의 [선택]으로 한 칸씩 지정하거나 ✕로 되돌릴 수 있습니다.", - "Use a card's [Select] to set one slot, or ✕ to clear it.", - ], - B03_Guide_How_Temp: [ - "[임시 보관함]은 대시보드에 미리 올려 둔 자료를 가져옵니다.", - "[Temporary storage] pulls files you staged on the dashboard.", - ], - B03_Guide_Notes_Title: ["알아 둘 것", "Before you upload"], - B03_Guide_Notes_Replace: [ - "이미 완료된 프로젝트에 다시 올리면 기존 분석 결과가 교체됩니다.", - "Uploading again to a finished project replaces the existing analysis.", - ], - B03_Guide_Notes_Crs: [ - "작업 좌표계는 라이다 PRJ가 기준 — 노선 좌표계와 다르면 업로드가 막힙니다.", - "The LiDAR PRJ sets the working CRS — a mismatched route CRS blocks the upload.", - ], - B03_Guide_Notes_LasFree: [ - "라이다가 없으면 [LAS 없는 설계]를 켜고 도엽 등고선으로 진행합니다.", - "With no LiDAR, turn on [Design without LAS] and work from sheet contours.", - ], B03_File_Selected_Title: ["선택한 파일", "Selected files"], B03_File_Selected_Empty: ["선택한 파일이 없습니다.", "No files selected."], B03_File_Upload_Button: ["파일 업로드", "Upload files"], diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index bcfd5c848..7dec8e9ff 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -528,10 +528,6 @@ export const ui_locales_b2 = { B06_Std_Field_CrossSlopeMax: ["횡단경사 최대(%)", "Cross slope max (%)"], B06_Std_Field_CutSlope: ["절토경사(1:n)", "Cut slope (1:n)"], B06_Std_Field_FillSlope: ["성토경사(1:n)", "Fill slope (1:n)"], - B06_Std_LType_Note: [ - "L형 측구는 각 횡단면도에서 선택합니다.", - "L-type ditch is chosen per cross-section drawing.", - ], B06_Std_Reset: ["기본값 복원", "Restore defaults"], B06_Std_ApplyAll: ["전체 측점 반영", "Apply to all stations"], B06_Std_ApplyAll_Success: [ @@ -563,10 +559,6 @@ export const ui_locales_b2 = { B06_Std_Diagram_Fill: ["성토경사", "Fill slope"], B06_Std_Diagram_CrossSlope: ["횡단경사", "Cross slope"], B06_Std_Diagram_Center: ["중심선(계획고)", "Centerline (design elev.)"], - B06_Std_Diagram_Caption: [ - "표준 편절편성 단면 모식도 — 각 설정값의 위치를 나타냅니다(실제 비율 아님).", - "Standard cut-fill section schematic — shows where each value applies (not to scale).", - ], /* --- B07_DesignDetail 상세 설계 --- */ B07_Design_Title: ["상세설계", "Detail Design"], diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index e25fb4236..d1201a47c 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -1,6 +1,7 @@ import "./ui_template_overlay.css"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { t } from "./ui_template_locale"; +import { placeHelpButton } from "./ui_template_help"; import { makePanelDraggable } from "./ui_template_overlay_drag"; // 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 // 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. @@ -126,6 +127,7 @@ export function splitSidebarActions(body: HTMLElement): void { while (container.firstChild && container.firstChild !== actions) scroll.append(container.firstChild); container.prepend(scroll); + placeHelpButton(actions); body.classList.add("ui-sidebar-split"); // body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장). for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) { diff --git a/ui_template/ui_template_workflow_layout.ts b/ui_template/ui_template_workflow_layout.ts index 08b3604cb..fb5a9c0e5 100644 --- a/ui_template/ui_template_workflow_layout.ts +++ b/ui_template/ui_template_workflow_layout.ts @@ -162,6 +162,12 @@ export function createStepBar( return bar; } +function helpOnlyRow(): HTMLElement { + const row = document.createElement("div"); + row.className = "ui-sidebar-actions"; + return row; +} + export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLayoutHandle { const root = document.createElement("div"); root.className = "ui-workflow-layout"; @@ -185,7 +191,8 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa }); const overlays = createWorkflowOverlays({ title: options.title, - optionsContent: options.leftPanel, + // 좌측 패널이 없는 화면(B08 · B09)도 [?] 줄은 둠(54-2) + optionsContent: options.leftPanel ?? helpOnlyRow(), progressContent, onOptionsOpenChange: (isOpen) => root.classList.toggle("is-options-open", isOpen), }); From 26c2e8b05fa237a0ce4d78048ce734ddc32a731b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:20:37 +0900 Subject: [PATCH 34/37] =?UTF-8?q?fix(b06):=20=ED=9A=A1=EB=8B=A8=EB=8F=84?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=86=92=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8=EB=84=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A0=90=EA=B2=80=20=C2=B7=20=EA=B3=A0=EC=B9=A8(53?= =?UTF-8?q?-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 다른 측점 부재를 누를 때 옛 카드 선택 풀기가 폼 · 선택 칸을 비우던 것 막음(첫 누름부터 잡힘) - 컨테이너 상자 강조가 폼 다시 그리기에 지워지던 것 · 옛 상자 강조 남던 것 · 유입 · 유출도 보이게 스크롤 - 고른 구조물 기준 측점 고침 = 바로 반영 · 횡단 카드 · 알약 · 선택 칸이 따라감 - 부재키는 같은 것을 다시 적을 때만 지킴 · 풀면 지움(B05 ↔ B06 오갈 때 옛 조정창 안 열림) - 카드 고름이 일반 측점 · 이음 카드 알약을 지우던 것 → 연 구조물 알약 지킴 - B05 되살리기 = 구조물 id 가 같은 측점 관보다 먼저 · B06 다시 들어올 때 넘김값 다시 적용 - 시험: ui_checks/53-1_횡단_고르기.js(점검 116 · 흠 0) · 선택 시험 새 규칙으로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/design/design_selection.ts | 17 +- A00_Common/design/design_structure_form.ts | 13 +- A00_Common/design/design_view_cross.ts | 11 + .../B05_Profile_UI_Structure_Pick_Session.ts | 3 +- .../B05_Profile_UI_Structures_Panel.ts | 2 + B06_Section/B06_Section_UI_Adjust_Dock.ts | 11 +- .../B06_Section_UI_Page_Structures_Panel.ts | 45 +- .../B06_Section_UI_Section_View_Profile.ts | 8 + resources/tester/helper_b05_b06_selection.cjs | 14 +- .../tester/test_b05_b06_selection_single.py | 17 +- .../tester/ui_checks/53-1_횡단_고르기.js | 607 ++++++++++++++++++ 11 files changed, 728 insertions(+), 20 deletions(-) create mode 100644 resources/tester/ui_checks/53-1_횡단_고르기.js diff --git a/A00_Common/design/design_selection.ts b/A00_Common/design/design_selection.ts index 4e7884a1d..d4c7a3d17 100644 --- a/A00_Common/design/design_selection.ts +++ b/A00_Common/design/design_selection.ts @@ -136,8 +136,9 @@ export function subscribeSelection(projectId: string | null, fn: SelectionListen } /** - * 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 이미 적힌 부재키를 지킨다 — 3D 로 - * 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다. + * 고름을 적고 구독자에게 알린다. `partKey` 를 안 주면 **같은 것**을 다시 적을 때만 부재키를 지킨다 — + * 3D 로 부재를 집은 직후 선택 동기화가 측점만 다시 적어도 조정창이 그대로 열려야 한다. + * 다른 구조물로 옮기면 버리고, 빈 값("")을 주면 지운다(53-1). * `from` 은 낸 쪽 이름 — 구독자가 제 것을 되받아 다시 그리지 않게 거른다. * 값이 그대로면 알리지 않고, 알림 도중 다시 낸 값은 적기만 한다(되울림 방지). */ @@ -157,7 +158,17 @@ export function selectDesign( before.structureId === selection.structureId && (options.partKey === undefined || options.partKey === before.partKey); if (same) return; - const key = options.partKey ?? before?.partKey; + // 부재키는 같은 것을 다시 적을 때만 지킴 — 다른 구조물로 옮기면 옛 부재(조정창)가 따라오지 않음(53-1) + const sameTarget = + before !== null && + before.chainageM !== null && + isNear(before.chainageM, selection.chainageM) && + before.structureId === selection.structureId; + // 빈 부재키("") = 부재를 풂 + const key = + options.partKey === "" + ? undefined + : (options.partKey ?? (sameTarget ? before?.partKey : undefined)); writeState( "structure-pick", { at: selection.chainageM, key, sid: selection.structureId ?? undefined }, diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 6e63dbbe9..0ae205ba6 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -176,7 +176,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return wrap; }); box.body.append(grid(...fields)); - if (c.required) box.body.append(slotOf(c.id)); + if (c.required) { + const slot = slotOf(c.id); + // B06 조정 강조(테두리 · 이름표)는 칸 상자에 붙음 — 다시 그려도 이어 감(53-1) + const prev = slot.closest("fieldset"); + if (prev?.classList.contains("is-active")) { + const legend = box.root.querySelector("legend")!; + legend.dataset.baseLabel = legend.textContent ?? ""; + legend.textContent = prev.querySelector("legend")?.textContent ?? legend.textContent; + box.root.classList.add("is-active"); + } + box.body.append(slot); + } if (links) { const key = linkOf(c, index + 1); const row = buildSummaryRow(); diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 3acaa9e8e..d20716f00 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -355,6 +355,11 @@ export async function mountCrossView( const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m); // 카드만 고른 것도 남긴다 — 종단 보기로 가면 그 시설이 그대로 열린다. if (!isWallSelecting()) selectDesign(projectId, opened, { from: "b06" }); + // 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면 + // 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1) + const pipeAt = structuresPanel.currentChainage(); + const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`); + if (mark) sectionView.selectMark(mark); structureSelection.syncInletStructure(); // 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다 // (2026-08-29 사용자). 벽을 찍어 카드가 딸려 선택된 경우는 건드리지 않는다. @@ -433,6 +438,10 @@ export async function mountCrossView( structuresPanel.prefer(handoff?.structureId ?? null); sectionView.focusStation(stationId); structuresPanel.prefer(null); + // 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1) + const sid = handoff?.structureId; + if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid)) + structuresPanel.selectStructure(sid); }, refreshCard: (chainageM) => sectionView.refreshCard(chainageM), revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key), @@ -492,6 +501,8 @@ export async function mountCrossView( // 계획고를 가게에서 다시 읽어 폼 · 카드 · 종단도를 그린다. 떠나면 카드 칸 관찰을 뗀다. host.onShow(() => { if (currentRouteId === null) return; + // 떠난 사이 종단 보기가 폼을 다른 것으로 바꿨을 수 있음 — 같은 넘김값도 다시 적용(53-1) + appliedPick = null; void structuresPanel.load().then(restoreStructurePick); void refreshDetailForStructures().then(() => reconcileStaleDesigns()); }); diff --git a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts index d4de1c8c0..585175a53 100644 --- a/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts +++ b/B05_Profile/B05_Profile_UI_Structure_Pick_Session.ts @@ -88,7 +88,8 @@ export function restoreStructurePick( selectStation(handoff.at); // 구조물 id 가 적혀 있을 때만 구조물로 연다 — 관 목록이 아직 안 실렸을 때 측점만으로 구조물을 // 짐작하면 그 측점을 덮는 다른 구조물이 열린다(관은 목록이 실릴 때 위 `selectStation` 이 연다). - if (!structures.hasSelection() && handoff.structureId) { + // id 가 있으면 측점이 연 관보다 먼저다(resolveSelection ① · 53-1) + if (handoff.structureId) { structures.selectAt({ chainageM: handoff.at, structureId: handoff.structureId }); } return structures.hasSelection(); diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 5eec636e4..05d04dad0 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -326,6 +326,8 @@ export function createStructuresSection( commitTempPipe(); syncOptionLock(); syncRangeDisplay(); + // 고르던 항목의 측점도 값 — 다른 칸처럼 바로 반영(53-1) + if (positionConfirmed) liveCommit(); } /** 타입의 옵션 스키마대로 입력 칸을 다시 그린다 — 그리는 규칙은 `_Structures_Panel_Options`. */ diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 7395f61ff..9c32dfbbe 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -109,6 +109,13 @@ function clearSideSlots(): void { // 추가 기슭막이 칸은 고른 다단 벽이 있을 때만 서 있는다 — 비면 그룹째 내린다. if (target === sideSlots?.extra) group.hidden = true; } + // 칸이 다른 명세로 옮겨 간 뒤 옛 상자에 남은 강조까지 끔(53-1) + document + .querySelectorAll("fieldset.is-active > legend[data-base-label]") + .forEach((legend) => { + legend.parentElement!.classList.remove("is-active"); + setGroupLabel(legend.parentElement!, ""); + }); } /** 행 DOM을 제자리 표식과 함께 옮긴다 — 숨은 행은 그대로 둔다. */ @@ -156,8 +163,8 @@ function relocateSideRows(panelRoot: HTMLElement): void { // 이름표에 지금 만지는 구조물을 적는다 — "유입구 (기슭막이)", "유출구 (기슭막이(독립))". // 추가 기슭막이 칸은 이름 자체가 그 단이라 괄호 안에 단 번호가 온다(2026-08-30 지시 1). setGroupLabel(group, panelRoot.dataset.panelTitle ?? ""); - // 다단은 칸이 새로 서는 것이라 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2). - if (side === "extra") group.scrollIntoView({ block: "nearest", behavior: "smooth" }); + // 고른 칸이 화면 밖일 수 있다 — 그 자리로 스크롤한다(지시 2 · 유입 · 유출도 53-1). + group.scrollIntoView({ block: "nearest" }); } function ensureDock(): void { diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 64f2f4592..2e2287053 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -38,8 +38,10 @@ import { } from "../common_util/common_util_summary_item"; import { readStructurePick } from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { + coversChainage, NO_SELECTION, PIPE_MATCH_M, + readSelection, resolveSelection, selectDesign, type DesignSelection, @@ -170,6 +172,23 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc pushMarks(); if (deps.projectId) writePendingStructures(deps.projectId, structures); deps.onStructuresChanged?.(); + // 고른 구조물을 다른 측점으로 옮기면 횡단 카드도 따라감(53-1) + const picked = readSelection(deps.projectId); + const moved = picked?.structureId + ? structures.find((entry) => entry.structure_id === picked.structureId) + : undefined; + if (moved && picked?.chainageM != null && !coversChainage(moved, picked.chainageM)) { + const id = moved.structure_id ?? null; + preferredId = id; + deps.focusChainage(structureAnchorM(moved)); + preferredId = null; + deps.markStructure?.(id); // 카드 고름이 옛 자리로 알약을 풀었으면 다시 세움 + selectDesign( + deps.projectId, + { chainageM: structureAnchorM(moved), structureId: id }, + { from: "b06" }, + ); + } }, onSelect: (structure) => { if (!structure) return; @@ -552,11 +571,17 @@ export function wireStructureSelection( const origRevet = stationControls.revetOffset.select; stationControls.revetOffset.select = (chainageM, key) => { - markWallSelecting(); + // 다른 카드 부재를 고르는 중에 옛 카드가 제 선택을 푸는 것 — 폼 · 선택 칸은 새 것 몫(53-1) + const nested = wallSelecting; + // 푸는 것은 고름이 아님 — 표시를 켜면 뒤이은 카드 고름이 선택 칸에 안 적힘(53-1) + if (key) markWallSelecting(); origRevet(chainageM, key); if (key) remember?.(ownerChainageOf(chainageM), key); if (key) panel.showAtChainage(ownerChainageOf(chainageM)); - else panel.releasePipe(); + else if (!nested) { + remember?.(ownerChainageOf(chainageM), ""); // 푼 부재가 B05 ↔ B06 오갈 때 다시 안 열림 + panel.releasePipe(); + } if (key) syncInletStructure(); // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 목적지 칸이 그제야 선다. refreshAdjustSlots(); @@ -564,22 +589,30 @@ export function wireStructureSelection( const origFord = stationControls.ford.select; stationControls.ford.select = (chainageM, role) => { - markWallSelecting(); + const nested = wallSelecting; + if (role) markWallSelecting(); origFord(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); - else panel.releasePipe(); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다 // (배관 기슭막이와 같은 순서, 2026-08-30 사용자 지시 1). refreshAdjustSlots(); }; const origBox = stationControls.box.select; stationControls.box.select = (chainageM, role) => { - markWallSelecting(); + const nested = wallSelecting; + if (role) markWallSelecting(); origBox(chainageM, role); if (role) remember?.(chainageM, role); if (role) panel.showAtChainage(chainageM); - else panel.releasePipe(); + else if (!nested) { + remember?.(chainageM, ""); + panel.releasePipe(); + } // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. refreshAdjustSlots(); }; diff --git a/B06_Section/B06_Section_UI_Section_View_Profile.ts b/B06_Section/B06_Section_UI_Section_View_Profile.ts index 4a177276d..2378a447c 100644 --- a/B06_Section/B06_Section_UI_Section_View_Profile.ts +++ b/B06_Section/B06_Section_UI_Section_View_Profile.ts @@ -15,6 +15,7 @@ import { type DesignPageShared, } from "../A00_Common/design/design_shared"; import { readState } from "../A00_Common/b_page_state"; +import { coversChainage } from "../A00_Common/design/design_selection"; import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch"; import type { CrossSection, @@ -133,6 +134,8 @@ export function createSectionProfile( let profile: DesignProfile | null = null; let detail: SectionDetailResponse | null = null; let marks: ReadonlyArray = []; + /** 마지막으로 세운 알약 — 카드 고름이 측점선을 맞출 때 지킬지 가름(53-1). */ + let markedId: string | null = null; let types: ReadonlyArray = []; let stations: IrregularStation[] = []; let intervalM = 20; @@ -253,6 +256,7 @@ export function createSectionProfile( profile?.redraw(); }, selectMark(structureId) { + markedId = structureId; profile?.setSelectedStructure(structureId); }, showCard(section) { @@ -265,6 +269,10 @@ export function createSectionProfile( (entry) => Math.abs(entry.chainage_m - section.chainage_m) < MATCH_M, ); profile.setSelectedStation(pipe ? irregularStationId(pipe.id) : section.station_id); + // 일반 측점은 측점선 짝 알약이 없어 꺼짐 — 고른 구조물이 이 측점을 덮으면 그 알약을 지킴(53-1) + const marked = markedId ? marks.find((entry) => entry.structure_id === markedId) : undefined; + if (!pipe && marked && coversChainage(marked, section.chainage_m)) + profile.setSelectedStructure(markedId); }, }; } diff --git a/resources/tester/helper_b05_b06_selection.cjs b/resources/tester/helper_b05_b06_selection.cjs index 8892c7e2f..e38ec452e 100644 --- a/resources/tester/helper_b05_b06_selection.cjs +++ b/resources/tester/helper_b05_b06_selection.cjs @@ -80,8 +80,10 @@ sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { partKey: "own", f out.store1 = sel.readSelection(P); sel.selectDesign(P, { chainageM: 205, structureId: "wall" }, { from: "b06" }); out.sameQuiet = heard.length; // 같은 값 · 부재키 생략 → 알리지 않음 +sel.selectDesign(P, { chainageM: 205.2, structureId: "wall" }, { from: "b06" }); +out.keyKept = sel.readSelection(P)?.partKey; // 같은 것을 다시 적으면 부재키를 지킨다 sel.selectDesign(P, { chainageM: 173.09, structureId: null }, { from: "b05" }); -out.keyKept = sel.readSelection(P)?.partKey; // 부재키를 안 주면 지킨다(옛 규칙) +out.keyDropped = sel.readSelection(P)?.partKey ?? null; // 다른 구조물로 옮기면 버린다(53-1) sel.selectDesign(P, sel.NO_SELECTION, { from: "b05" }); out.cleared = sel.readSelection(P); out.heardFrom = heard.map((h) => [h.chainageM, h.structureId, h.from]); @@ -150,7 +152,7 @@ out.apply = { }; // ── B05 진입 때 되살리기 — 구조물 id 가 있을 때만 구조물로 연다 ───────────── -const restoreRun = (pickValue) => { +const restoreRun = (pickValue, stationOpens = false) => { sel.selectDesign(P, sel.NO_SELECTION); if (pickValue) sel.selectDesign(P, pickValue.selection, { partKey: pickValue.key }); const calls = []; @@ -163,15 +165,17 @@ const restoreRun = (pickValue) => { }, }; const controls = { select: (p) => calls.push(["3d", p.chainageM, p.key ?? null]) }; - const done = pickSession.restoreStructurePick(controls, P, section, (c) => - calls.push(["station", c]), - ); + const done = pickSession.restoreStructurePick(controls, P, section, (c) => { + calls.push(["station", c]); + opened = stationOpens; // 같은 측점 관이 먼저 열림 + }); return { calls, done }; }; out.restore = { pipeOnly: restoreRun({ selection: { chainageM: 85.05, structureId: null } }), structure: restoreRun({ selection: { chainageM: 205, structureId: "wall" }, key: "own" }), nothing: restoreRun(null), + structureOverPipe: restoreRun({ selection: { chainageM: 440.24, structureId: "mirror" } }, true), }; sel.selectDesign(P, sel.NO_SELECTION); diff --git a/resources/tester/test_b05_b06_selection_single.py b/resources/tester/test_b05_b06_selection_single.py index 13728118d..33b63cf3e 100644 --- a/resources/tester/test_b05_b06_selection_single.py +++ b/resources/tester/test_b05_b06_selection_single.py @@ -105,13 +105,19 @@ def test_세션에_적고_읽는다(out): def test_부재키를_안_주면_지키고_해제하면_비운다(out): assert out["keyKept"] == "own" + assert out["keyDropped"] is None # 다른 구조물로 옮기면 옛 부재가 안 따라옴(53-1) assert out["cleared"] is None def test_같은_값은_알리지_않고_출처를_함께_알린다(out): assert out["sameQuiet"] == 1 - assert out["heardFrom"] == [[205, "wall", "b06"], [173.09, None, "b05"], [None, None, "b05"]] - assert out["afterOff"] == 3 # 구독을 끊으면 더는 안 들림 + assert out["heardFrom"] == [ + [205, "wall", "b06"], + [205.2, "wall", "b06"], + [173.09, None, "b05"], + [None, None, "b05"], + ] + assert out["afterOff"] == 4 # 구독을 끊으면 더는 안 들림 def test_알림_도중_다시_낸_값은_적기만_한다(out): @@ -172,6 +178,13 @@ def test_B05_되살리기_적힌_것이_없으면_끝난_것(out): assert out["restore"]["nothing"] == {"calls": [], "done": True} +def test_B05_되살리기_같은_측점_관보다_구조물_id가_먼저(out): + # B06 에서 고른 반사경(22+0) — 측점이 관을 먼저 열어도 id 의 구조물로 연다(53-1) + over = out["restore"]["structureOverPipe"] + assert over["calls"][-1] == ["selectAt", 440.24, "mirror"] + assert over["done"] is True + + # ── 배선 ─────────────────────────────────────────────────────────────── def test_횡단도_벽_구체_눌러도_폼은_같은_길_하나(): panel = _read("B06_Section", "B06_Section_UI_Page_Structures_Panel.ts") diff --git a/resources/tester/ui_checks/53-1_횡단_고르기.js b/resources/tester/ui_checks/53-1_횡단_고르기.js new file mode 100644 index 000000000..c6025557e --- /dev/null +++ b/resources/tester/ui_checks/53-1_횡단_고르기.js @@ -0,0 +1,607 @@ +/* 53-1 — B06 횡단도에서 구조물을 누르면 좌측 패널이 그 구조물로 켜지는가(전수) + * 시작 화면: B06 횡단설계(#/b06-section) · 창마다 자기 검증 프로젝트 · 로그인 상태 + * 부르는 법: orca eval 은 30초 안팎에 끊김 — 단계는 시작만 하고 결과는 따로 읽음 + * orca eval --expression "window.__m531='setup'; $(cat 이 파일)" → 초안 사진 + 표 20종 중 없는 종류를 세션에만 넣음(저장 안 함) + * orca eval --expression "window.__m531='check'; $(cat 이 파일)" → 전수 목록(벽 · 구체 · 카드 · 같은 측점 · 값 · 옮기기) + * orca eval --expression "window.__m531='handoff'; $(cat 이 파일)" → 저장 안 하고 B06 → B05 → B06 + * orca eval --expression "window.__m531='restore'; $(cat 이 파일)" → 초안을 사진대로 · 뒤에 orca reload + * 결과: orca eval --expression "JSON.stringify(window.__r531)" (끝나기 전엔 null) + * 쓰는 것: sessionStorage 만(서버 저장 없음 · 공유 storage 안 씀) + * 기대 수치: check · handoff 의 bad = [] · rows 마다 ok = true + * ① 종류 · 기준 측점 = 누른 것 ② 컨테이너 몫(유입 · 유출 · N단 · 좌우)이면 그 상자 강조 + 보임 + * ③ 목록 강조 1 · 종단 알약 1 ④ 좌측 값 → 횡단 다시 그림 ⑤ 다시 누름 = 풂 · 빈 곳 · 같은 측점 · 옮기기 · B05 ↔ B06 + */ +(() => { + const MODE = window.__m531 || "check"; + window.__r531 = null; + const PROJECT = localStorage.getItem("frd_current_project_id"); + const SNAP = "ui531-snap"; + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 20000) => { + for (let t = 0; t < ms; t += 250) { + if (fn()) return true; + await w(250); + } + return false; + }; + const sec = () => document.querySelector(".b05-structure-section"); + const selects = () => [...sec().querySelectorAll(".b05-structure__grid select")]; + const fire = (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + }; + const flat = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const listRows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const cards = () => [...document.querySelectorAll("article.b06-cross-card")]; + const chainOf = (card) => { + const m = card.id.match(/(\d{9})(\d{3})$/); + return m ? Number(m[1]) + Number(m[2]) / 1000 : NaN; + }; + const selectedCard = () => document.querySelector(".b06-cross-card--selected"); + const click = (el) => el.dispatchEvent(new MouseEvent("click", { bubbles: true })); + /** 지금 좌측 패널 · 목록 · 알약 · 선택 칸. */ + const state = () => { + const pickKey = Object.keys(sessionStorage).find( + (k) => k.includes("structure-pick") && k.includes(PROJECT), + ); + let pick = null; + try { + pick = JSON.parse(sessionStorage.getItem(pickKey)); + } catch { + /* 없음 */ + } + const box = [...document.querySelectorAll("fieldset.is-active")].map((f) => { + const r = f.getBoundingClientRect(); + let sc = f.parentElement; + while (sc && !/(auto|scroll)/.test(getComputedStyle(sc).overflowY)) sc = sc.parentElement; + const b = sc ? sc.getBoundingClientRect() : { top: 0, bottom: innerHeight }; + const seen = r.height > 0 && r.top < Math.min(b.bottom, innerHeight) && r.bottom > b.top; + return { legend: flat(f.querySelector("legend")), seen }; + }); + const anchor = sec().querySelectorAll(".b05-structure__position-row > label")[2]; + return { + type: selects()[1]?.value ?? "", + anchor: anchor ? [...anchor.querySelectorAll("input")].map((i) => i.value).join("+") : "", + pick, + box, + list: listRows() + .filter((li) => li.classList.contains("is-selected")) + .map(flat), + pill: [...document.querySelectorAll(".b05-structure__mark.is-selected")].map((m) => m.title), + card: selectedCard()?.id ?? null, + }; + }; + const bad = []; + const known = []; // 고치지 않은 흠(횡단 그리기 몫) — 보고에 올림 + const rows = []; + const judge = (name, ok, s, note = "") => { + rows.push({ + name, + ok, + note, + type: s.type, + key: s.pick?.key ?? null, + box: s.box, + list: s.list, + pill: s.pill.length, + card: s.card?.slice(-9) ?? null, + }); + if (!ok) bad.push(name); + }; + /** 부재키 → 강조돼야 할 상자 이름. own(사면 구조물)은 컨테이너가 없음. */ + const boxFor = (key) => + key === "inlet" + ? /유입|좌|우/ + : key === "outlet" + ? /유출|좌|우/ + : /^b?extra/.test(key ?? "") + ? /추가/ + : key === "left" || key === "right" + ? /유입|유출/ + : null; + const boxOk = (s) => { + const want = boxFor(s.pick?.key); + if (!want) return s.box.length === 0; + return s.box.length === 1 && want.test(s.box[0].legend) && s.box[0].seen; + }; + const commonOk = (s) => + !!s.type && s.list.length === 1 && s.pill.length === 1 && !!s.pick && boxOk(s); + const hitsOf = (card) => [ + ...card.querySelectorAll(".b06-chart__culvert-revet-hit, .b06-chart__box-hit"), + ]; + const uniqueHits = (card) => [...new Set(hitsOf(card))]; + /** 카드 고름을 풂(같은 카드 다시 누름 = 풂). */ + const clearCard = async () => { + const c = selectedCard(); + if (c) { + click(c.querySelector("svg")); + await w(900); + } + }; + const openProfile = async () => { + const prof = document.querySelector(".b05-route-profile"); + if (prof?.classList.contains("is-collapsed")) { + document.querySelector(".ui-workflow-overlay__handle--bottom")?.click(); + await w(2500); + } + // 뒤에 깔린 탭은 그리기가 밀림 — 알약이 설 때까지 크기 알림을 보냄 + for (let i = 0; i < 20 && !document.querySelector(".b05-structure__mark"); i++) { + dispatchEvent(new Event("resize")); + await w(500); + } + }; + /** 세션에만 넣는 종류(표 01~08) — 이미 있으면 건너뜀. [군, 종류, 측점, +m, 길이(구간형)] */ + const ADD = [ + ["G", "pavement_concrete", 22, 0, 10], + ["A", "cross_underdrain", 22, 0], + ["E", "mirror", 22, 0], + ["A", "ford_pavement", 3, 0], + ["A", "open_ditch", 5, 0], + ["A", "cross_drain_exposed", 6, 0], + ["E", "evac_road", 9, 0], + ["E", "guardrail", 11, 0, 10], + ["E", "curb", 14, 0], + ["E", "hazard_sign", 15, 0], + ["E", "position_sign", 18, 0], + ["E", "water_intake", 19, 0], + ["E", "completion_sign", 20, 0], + ["E", "barrier_gate", 21, 0], + ["E", "spoil_bank", 24, 0], + ["E", "borrow_pit", 25, 0], + ["호환", "etc", 27, 0], + ]; + const addOne = async ([g, t, no, plus, len]) => { + const btn = (label) => [...sec().querySelectorAll("button")].find((b) => flat(b) === label); + btn("리셋")?.click(); + await w(400); + const n = listRows().length; // A군은 측점을 넣는 순간 임시로 목록에 섬 + const [gs, ts] = selects(); + fire(gs, g); + await w(300); + if (![...ts.options].some((o) => o.value === t)) return `${t}: 종류 없음`; + fire(ts, t); + await w(1200); + const labels = [...sec().querySelectorAll(".b05-structure__position-row > label")]; + const anchor = labels[2].querySelectorAll("input"); + fire(anchor[0], String(no)); + await w(200); + fire(anchor[1], String(plus)); + await w(300); + const field = (re) => + [...sec().querySelectorAll("label")] + .filter((l) => l.offsetParent) + .find((l) => re.test(flat(l))) + ?.querySelector("input"); + if (len) { + // 구간형 — 길이 · 전(시작 = 기준 − 전), 사토장은 시작 · 종료 칸 + if (field(/^길이/)) { + fire(field(/^길이/), String(len)); + fire(field(/^기준측점 전/), String(len / 2)); + } + if (!labels[1].hidden) { + const [s0, s1] = labels[1].querySelectorAll("input"); + const [e0, e1] = labels[3].querySelectorAll("input"); + fire(s0, String(no - 1)); + fire(s1, "10"); + fire(e0, String(no)); + fire(e1, "10"); + } + await w(300); + } else if (!labels[1].hidden) { + const [s0, s1] = labels[1].querySelectorAll("input"); + const [e0, e1] = labels[3].querySelectorAll("input"); + fire(s0, String(no - 1)); + fire(s1, "10"); + fire(e0, String(no)); + fire(e1, "10"); + await w(300); + } + btn("추가")?.click(); + await w(1500); + return listRows().length > n ? null : `${t}: 안 들어감`; + }; + + const run = async () => { + // 뒤에 깔린 탭(ORCA)은 화면 프레임이 안 돎 — rAF 를 기다리는 넘김 · 스크롤이 멈춤. 시험 동안만 시간으로 대신 + let frame = false; + requestAnimationFrame(() => (frame = true)); + await w(300); + if (!frame && !window.__raf531) { + window.__raf531 = window.requestAnimationFrame; + window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); + } + await until(() => listRows().length > 0 && cards().length > 0); + await w(1000); + const out = { mode: MODE, project: PROJECT }; + + if (MODE === "setup") { + if (!sessionStorage.getItem(SNAP)) + sessionStorage.setItem( + SNAP, + JSON.stringify( + Object.entries(sessionStorage).filter(([k]) => k.startsWith("aislo:draft:")), + ), + ); + const have = new Set(listRows().map((li) => flat(li).replace(/^[\d+~ ]+/, ""))); + out.added = []; + for (const item of ADD) { + const [g, t] = item; + selects()[0] && fire(selects()[0], g); + await w(200); + const name = [...selects()[1].options].find((o) => o.value === t)?.text ?? t; + if ([...have].some((h) => h.startsWith(name))) continue; + const err = await addOne(item); + out.added.push(err ?? name); + if (err) bad.push(err); + } + sec() + .querySelectorAll("button") + .forEach((b) => flat(b) === "리셋" && b.click()); + // 다단 벽(추가 기슭막이 N단) — 관 유출 벽에 한 단 더함(세션만 · 단이 설 자리가 있는 관까지 차례로) + out.tier = null; + for (const card of cards().filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) { + if (/추가\(성토부\)/.test(card.innerHTML)) { + out.tier = card.id; + break; + } + const outlet = () => + [ + ...document.getElementById(card.id).querySelectorAll(".b06-chart__culvert-revet-hit"), + ].find((h) => /유출/.test(h.querySelector("title")?.textContent ?? "")); + click(outlet()); + await w(1400); + [...sec().querySelectorAll(".b06-structure-panel__struct--tier button")] + .find((b) => b.offsetParent && /추가/.test(b.title)) + ?.click(); + await w(2500); + const added = /추가\(성토부\)/.test(document.getElementById(card.id).innerHTML); + click(outlet()); // 풂 + await w(1000); + if (added) { + out.tier = card.id; + break; + } + } + if (!out.tier) bad.push("다단 벽 못 세움"); + out.rows = listRows().map(flat); + } + + if (MODE === "check") { + await openProfile(); + // ── 가. 횡단도에 그려진 고를 거리 전부 — 다른 카드에서 처음 누름 · 같은 카드 다른 부재 ── + await clearCard(); + const targets = cards().filter((c) => hitsOf(c).length); + for (const card of targets) { + const n = uniqueHits(card).length; + for (let i = 0; i < n; i++) { + const hit = uniqueHits(document.getElementById(card.id))[i]; + const before = state(); + click(hit); + await w(1400); + let s = state(); + // 이미 고른 부재였으면 풀렸을 것 — 한 번 더 눌러 고름 + if (!s.pick?.key && before.pick?.key) { + click(uniqueHits(document.getElementById(card.id))[i]); + await w(1400); + s = state(); + } + judge(`벽 ${card.id.slice(-9)}#${i}`, commonOk(s) && s.card === card.id, s); + } + } + // ── 나. 같은 부재 다시 누름 = 풂(카드 고름은 남음) ── + const t0 = targets[0]; + if (t0) { + click(uniqueHits(t0)[0]); + await w(1400); + if (!state().pick?.key) { + click(uniqueHits(document.getElementById(t0.id))[0]); + await w(1400); + } + click(uniqueHits(document.getElementById(t0.id))[0]); + await w(1400); + const s = state(); + judge("다시 누름 = 풂", !s.pick?.key && s.box.length === 0 && s.card === t0.id, s); + } + // ── 다. 빈 곳 — 구조물 카드 · 빈 카드 · 다시 누름 = 카드 풂 ── + await clearCard(); + const pipeCard = targets.find((c) => /유입 집수정|유출 기슭막이/.test(c.innerHTML)); + if (pipeCard) { + click(pipeCard.querySelector("svg")); + await w(1400); + let s = state(); + judge("빈 곳(구조물 카드)", !!s.type && !s.pick?.key && s.box.length === 0, s); + click(document.getElementById(pipeCard.id).querySelector("svg")); + await w(1400); + s = state(); + judge("빈 곳 다시 = 카드 풂", !s.card && !s.type && !s.pick, s); + } + const used = new Set(listRows().map((li) => flat(li).match(/^(\d+)\+/)?.[1])); + const empty = cards().find((c) => !used.has(String(Math.floor(chainOf(c) / 20)))); + if (empty) { + click(empty.querySelector("svg")); + await w(1400); + const s = state(); + judge("빈 카드", !s.type && s.box.length === 0 && s.card === empty.id, s); + await clearCard(); + } + // ── 라. 표 20종 — 목록으로 골라 종류 확인 → 그 카드 빈 곳 눌러 같은 것이 켜지나 ── + const rowsNow = listRows().map(flat); + for (const text of rowsNow) { + const li = listRows().find((l) => flat(l) === text); + if (!li) continue; + await clearCard(); + (li.querySelector("button") ?? li).click(); + await w(1400); + const viaList = state(); + const card = viaList.card && document.getElementById(viaList.card); + judge( + `목록 ${text}`, + !!viaList.type && viaList.list.length === 1 && !!card && viaList.pill.length === 1, + viaList, + ); + if (!card) continue; + await clearCard(); + click(card.querySelector("svg")); + await w(1400); + const viaCard = state(); + const same = viaCard.type === viaList.type; + if (same) + judge(`카드 ${text}`, viaCard.list.length === 1 && viaCard.pill.length === 1, viaCard); + // 같은 카드에 관(그려진 것)이 있으면 관이 먼저 — 그림 없는 구조물은 목록으로만(흠 · 그리기 몫) + else if (uniqueHits(card).length && viaCard.type) + known.push(`카드 ${text} → ${viaCard.list[0] ?? viaCard.type}`); + else judge(`카드 ${text}`, false, viaCard, `카드는 ${viaCard.type || "빈 것"}`); + } + await clearCard(); + // ── 마. 같은 측점 여럿 — 목록 구조물 → 그 카드 관 벽 → 다시 목록 ── + const shared = rowsNow.find( + (t) => + !/배관/.test(t) && + rowsNow.some((u) => /배관/.test(u) && u.split(" ")[0] === t.split(" ")[0]), + ); + if (shared) { + const pipeRow = rowsNow.find( + (u) => /배관/.test(u) && u.split(" ")[0] === shared.split(" ")[0], + ); + const pick = (t) => { + const li = listRows().find((l) => flat(l) === t); + (li.querySelector("button") ?? li).click(); + }; + pick(pipeRow); + await w(1400); + const pc = selectedCard(); + pick(shared); + await w(1400); + let s = state(); + judge(`같은 측점: 목록 ${shared}`, s.type !== "pipe" && !s.pick?.key && !!s.pick?.sid, s); + const wall = pc && uniqueHits(document.getElementById(pc.id))[0]; + if (wall) { + click(wall); + await w(1400); + s = state(); + judge("같은 측점: 그 카드 관 벽", s.type === "pipe" && commonOk(s), s); + } + pick(shared); + await w(1400); + s = state(); + judge("같은 측점: 다시 목록", s.type !== "pipe" && !s.pick?.key && !!s.pick?.sid, s); + } + // ── 바. 좌측 값 → 횡단 다시 그림 → 되돌림(관 유출 형태 · 세월교 련 수 · 박스 유입 구조) ── + const titles = (card) => + [...document.getElementById(card.id).querySelectorAll("title")].map((t) => t.textContent); + const ctlOf = (key) => + [...sec().querySelectorAll(`[data-spec-item="${key}"]`)] + .find((e) => e.offsetParent) + ?.querySelector("input, select"); + const bodyCase = async (card, key, value, re) => { + await clearCard(); + click(uniqueHits(document.getElementById(card.id))[0]); + await w(1400); + const ctl = ctlOf(key); + if (!ctl) return judge(`값 → 횡단: ${key}`, false, state(), "칸 없음"); + const was = ctl.value; + const before = titles(card).join("|"); + fire(ctl, value); + await w(3000); + const after = titles(card).join("|"); + judge( + `값 → 횡단: ${key}=${value}`, + re(after) && after !== before && boxOk(state()), + state(), + ); + fire(ctlOf(key), was); + await w(3000); + const back = titles(card).join("|"); + // 빈 칸 = 자동(24-4a) — 서버가 채운 기본 연장을 앞단이 지워 「연장」 표기만 빠짐(흠 · 저장하면 다시 섬) + const noSpan = (t) => t.replace(/ · 연장 [\d.]+m/g, ""); + if (back !== before && noSpan(back) === noSpan(before)) + known.push( + `값 되돌림 ${key}: 벽 설명의 「연장」 표기 빠짐(서버 기본값 · 저장 뒤 다시 섬)`, + ); + judge(`값 되돌림: ${key}`, noSpan(back) === noSpan(before), state()); + }; + // 형태 칸은 구조를 고른 관에서만 섬 — 그 칸이 서는 관을 찾음 + for (const c of targets.filter((c) => /유출 기슭막이 선택/.test(c.innerHTML))) { + await clearCard(); + click(uniqueHits(document.getElementById(c.id))[0]); + await w(1400); + if (!ctlOf("outlet.form")) continue; + await bodyCase(c, "outlet.form", "콘크리트", (t) => /유출 기슭막이 콘크리트/.test(t)); + break; + } + const fordCard = targets.find((c) => /련/.test(titles(c).join("|"))); + if (fordCard) await bodyCase(fordCard, "ford.count", "3", (t) => /3련/.test(t)); + const boxCard = targets.find((c) => c.querySelector(".b06-chart__box-hit")); + if (boxCard) + await bodyCase( + boxCard, + "inlet.structure", + "없음", + (t) => (t.match(/날개벽 구간/g) ?? []).length === 1, + ); + // ── 사. 옮기기 — 사면 구조물 기준 측점 +2 → 목록 · 카드 · 알약 · 선택 칸이 따라감 → 되돌림 ── + await clearCard(); + // 사면 구조물 차례로 — 필수 칸이 빈 옛 구조물은 측점을 바꿔도 반영이 막히고 그 칸으로 포커스만 감(흠) + const input = () => + sec() + .querySelectorAll(".b05-structure__position-row > label")[2] + .querySelectorAll("input")[0]; + const move = async (v) => { + const el = input(); + el.focus(); + fire(el, String(v)); + el.blur(); + el.dispatchEvent(new FocusEvent("focusout", { bubbles: true })); + await w(8000); + return state(); + }; + for (const c of targets.filter((c) => + /독립 기슭막이/.test(c.querySelector(".b06-chart__culvert-revet title")?.textContent ?? ""), + )) { + await clearCard(); + click(uniqueHits(document.getElementById(c.id))[0]); + await w(1400); + const s0 = state(); + const no = Number(input().value); + let s = await move(no + 2); + if (s.list[0] === s0.list[0]) { + const stuck = flat(document.activeElement?.closest("label") ?? document.body).slice( + 0, + 12, + ); + known.push(`옮기기 ${s0.list[0]}: 반영 안 됨 — 빈 필수 칸 「${stuck}」 으로 포커스만`); + await move(no); + continue; + } + judge( + "옮기기: 목록 · 카드 · 알약 · 선택 칸", + s.list[0]?.startsWith(`${no + 2}+`) && + Math.abs(chainOf(document.getElementById(s.card)) - (no + 2) * 20) < 1 && + s.pill.length === 1 && + s.pick?.sid === s0.pick?.sid && + Math.abs(s.pick.at - (no + 2) * 20) < 1, + s, + ); + s = await move(no); + judge( + "옮기기 되돌림", + s.list[0] === s0.list[0] && + Math.abs(chainOf(document.getElementById(s.card)) - no * 20) < 1 && + s.pill.length === 1, + s, + ); + break; + } + await clearCard(); + } + + if (MODE === "handoff") { + await openProfile(); + const go = async (hash) => { + location.hash = hash; + await until(() => listRows().length > 0 && selects()[1]?.value); + await w(5000); + return state(); + }; + if (!location.hash.includes("b06")) await go("#/b06-section"); + // 같은 측점에 다른 구조물이 있는 관을 고름(없으면 아무 관) + const texts = listRows().map(flat); + const pipeRow = + texts.find( + (t) => + /배관/.test(t) && + texts.some((u) => !/배관/.test(u) && u.split(" ")[0] === t.split(" ")[0]), + ) ?? texts.find((t) => /배관/.test(t)); + await clearCard(); + for (let i = 0; i < 2 && !selectedCard(); i++) { + // 목록 다시 누름 = 풂 — 이미 골라져 있었으면 한 번 더 + const li = listRows().find((l) => flat(l) === pipeRow); + (li.querySelector("button") ?? li).click(); + await w(1400); + } + const pipeCard = selectedCard(); + const outletHit = () => + uniqueHits(document.getElementById(pipeCard.id)).find((h) => + /유출/.test(h.querySelector("title")?.textContent ?? ""), + ); + await clearCard(); + click(outletHit()); + await w(1400); + const a = state(); + const b05 = await go("#/b05-profile"); + const b06 = await go("#/b06-section"); + judge( + "관 유출 → B05", + b05.type === a.type && b05.anchor === a.anchor && b05.list[0] === a.list[0], + b05, + ); + judge( + "B05 → B06 유출 그대로", + b06.pick?.key === "outlet" && boxOk(b06) && b06.list[0] === a.list[0], + b06, + ); + // 같은 측점 다른 구조물을 목록으로 — 부재키가 따라오지 않고 그 구조물이 열림 + const station = a.list[0]?.split(" ")[0]; + const other = listRows().find( + (li) => flat(li).startsWith(station + " ") && !/배관/.test(flat(li)), + ); + if (other) { + const otherText = flat(other); + (other.querySelector("button") ?? other).click(); + await w(1400); + const c = state(); + const b05b = await go("#/b05-profile"); + const b06b = await go("#/b06-section"); + judge("같은 측점 구조물 → B05", b05b.type === c.type && !b05b.pick?.key, b05b); + judge("B05 → B06 그 구조물", b06b.type === c.type && b06b.box.length === 0, b06b); + // B06 에서 관 카드를 고른 채 B05 에서 같은 측점 다른 구조물을 고르고 돌아옴 — 카드가 이미 골라져 있어도 그 구조물 + const pick = (t) => { + const row = listRows().find((l) => flat(l) === t); + (row.querySelector("button") ?? row).click(); + }; + pick(pipeRow); + await w(1400); + await go("#/b05-profile"); + pick(otherText); + await w(1400); + const d = state(); + const b06c = await go("#/b06-section"); + judge( + "B06 카드 골라진 채 B05 에서 다른 것 → B06", + b06c.type === d.type && b06c.type !== "pipe", + b06c, + ); + } + } + + if (MODE === "restore") { + const snap = sessionStorage.getItem(SNAP); + if (!snap) out.error = "사진 없음"; + else { + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("aislo:draft:") || k.includes("section-detail")) + sessionStorage.removeItem(k); + for (const [k, v] of JSON.parse(snap)) sessionStorage.setItem(k, v); + sessionStorage.removeItem(SNAP); + const sorted = (list) => JSON.stringify([...list].sort(([a], [b]) => a.localeCompare(b))); + out.same = + sorted(Object.entries(sessionStorage).filter(([k]) => k.startsWith("aislo:draft:"))) === + sorted(JSON.parse(snap)); + } + } + + if (window.__raf531) { + window.requestAnimationFrame = window.__raf531; + delete window.__raf531; + } + out.count = rows.length; + out.bad = bad; + out.known = known; + out.rows = out.rows ?? rows; + return out; + }; + run() + .then((out) => (window.__r531 = out)) + .catch((e) => (window.__r531 = { error: String(e?.stack ?? e) })); + return "started"; +})(); From eafec12d8525cb48b21c70f98d4cd15e644cd618 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:24:08 +0900 Subject: [PATCH 35/37] =?UTF-8?q?chore(plan):=2010-6=20=EC=82=AC=EB=A9=B4?= =?UTF-8?q?=EC=95=88=EC=A0=95=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index f1a32c755..84c917f70 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -154,3 +154,6 @@ 공통 - 기존 배관에서 박스암거로 변경할 경우 횡단도의 그림이 업데이트 안됨. 확인 및 업데이트 - 추가로 관련된 정보 중 확인해줘야 할 내용이 기존에 화면의 B05/B06의 실시간 제어와 동작 속도/보안 개선을 위해 많은 작업이 있었는데 좌측 패널 통합 이후 많이 느려진 걸로 보임. 재확인. 이건 확인부터 하는게 좋겠음. + +(10-6 사용자 · 추가 요청) + - 사면 안정 그룹의 종류들 전체가 컨테이너 구성이 안되어 있음. 현재 기준 측점을 제외한 옵션들을 하나의 컨테이너에 넣고 그 안에 집계 항목 지정 넣을것 From e01c0efde6cd2cf213ed1e2885d44cc403c1bbaf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:25:50 +0900 Subject: [PATCH 36/37] =?UTF-8?q?chore(plan):=2010-6=20=EC=82=AC=ED=86=A0?= =?UTF-8?q?=EC=9E=A5=20=EA=B8=B0=EC=A4=80=20=EC=B8=A1=EC=A0=90=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EC=9B=90=EB=AC=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FJqWQpoB1RE7hiaQ2wH8qV --- docs/raw/사용자요구/2026-10-05_수정요구.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 84c917f70..740f9584f 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -157,3 +157,6 @@ (10-6 사용자 · 추가 요청) - 사면 안정 그룹의 종류들 전체가 컨테이너 구성이 안되어 있음. 현재 기준 측점을 제외한 옵션들을 하나의 컨테이너에 넣고 그 안에 집계 항목 지정 넣을것 + +(10-6 사용자 · 추가 요청) + - 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성 From 1e02ecc19606efe097e99f0b105704f57a49e040 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:26:34 +0900 Subject: [PATCH 37/37] =?UTF-8?q?fix(structure):=20=EA=B4=80=20=EC=A7=80?= =?UTF-8?q?=EC=A0=90=20=EC=8B=9C=EC=84=A4=20=EC=A2=85=EB=A5=98=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EA=B8=B0=20=EB=92=A4=20B06=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=C2=B7=20=EC=95=8C=EC=95=BD=20=C2=B7=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=94=EB=A1=9C=20=EC=83=88=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?(54-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 패널 종류 바꿈 = 고른 관 지점 실시간 반영 - B06 관 고침이 시설 종류를 받음 · 옛 종류 칸 안 이음 · 알약 · 횡단 다시 그림 - 횡단 상세 읽기에 초안 종류 얹기(짝 산식 common_util_culvert_sets) · 카드 제목 라벨 포함 - 상세 조회가 측점 라벨을 지금 관 지점 종류로(저장 뒤 다시 열기) - 구조물 다시 그림 최신 요청만 채택 - 시험: test_b05_pipe_station_relabel · ui_checks 54-4_종류바꾸기_횡단 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/design/design_view_cross.ts | 6 +- B05_Profile/B05_Profile_Engine_Sections.py | 65 +++++- .../B05_Profile_UI_Structures_Panel_Events.ts | 2 + B06_Section/B06_Section_Router.py | 2 + B06_Section/B06_Section_Section_Store.ts | 68 ++++++ .../B06_Section_UI_Page_Structures_Panel.ts | 17 +- .../tester/test_b05_pipe_station_relabel.py | 74 ++++++ .../tester/ui_checks/54-4_종류바꾸기_횡단.js | 211 ++++++++++++++++++ 8 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 resources/tester/test_b05_pipe_station_relabel.py create mode 100644 resources/tester/ui_checks/54-4_종류바꾸기_횡단.js diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 3acaa9e8e..a534c03dc 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -203,11 +203,15 @@ export async function mountCrossView( view: () => sectionView, }); + let structuresRefreshSeq = 0; /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; + const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) try { - sectionDetail = await loadSectionDetail(projectId, currentRouteId); + const fresh = await loadSectionDetail(projectId, currentRouteId); + if (seq !== structuresRefreshSeq) return; + sectionDetail = fresh; // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측). if (sectionDetail) { diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index f804f7e13..c2c8c88c8 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -8,6 +8,7 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB import json import logging +import re from dataclasses import replace from pathlib import Path from typing import Any @@ -33,6 +34,7 @@ from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_REVET, PipePoint, load_pipe_points_file, + parse_pipe_points, pipe_anchor_clearances, route_signature, ) @@ -205,6 +207,56 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P return points +def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None: + """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음.""" + if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: + return None + if pipe.facility == PIPE_FACILITY_PIPE: + values = pipe.options or {} + kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) + diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM)) + return f"{kind} D{diameter}" + definition = types.get(pipe.facility) + return definition.name if definition is not None else None + + +def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None: + """횡단 측점 라벨을 **지금 관 지점 종류**로 맞춘다(54-4). + + 라벨은 측점 생성 때 적혀 파일에 남는다 — 저장으로 종류만 바꾸면(배관 → BOX암거) 측점은 + 다시 안 만들어져 카드 제목이 옛 종류로 남는다. 관 지점 라벨(종류 이름 · 「관종 D관경」)인 + 측점만 고치고 다른 구조물 라벨은 건드리지 않는다. + """ + path = ( + project_root + / "B04_PreProcess" + / DRAINAGE_CACHE_DIRNAME + / DRAINAGE_EDITS_DIRNAME + / DRAINAGE_PIPE_POINTS_FILENAME + ) + try: + points = parse_pipe_points(json.loads(path.read_text(encoding="utf-8")).get("points")) + except (OSError, ValueError, AttributeError): + return + types = structure_type_map() + pipe_names = {definition.name for definition in types.values() if definition.managed_by} + for section in cross_sections: + label = section.get("structure") + if not isinstance(label, str) or not ( + label in pipe_names or re.fullmatch(r"\S+ D\d+", label) + ): + continue + chainage = float(section.get("chainage_m") or 0.0) + pipe = next((p for p in points if abs(p.chainage_m - chainage) < 0.02), None) + if pipe is None: + continue + fresh = pipe_station_label(pipe, types) + if fresh is None: + section.pop("structure", None) + else: + section["structure"] = fresh + + def resolve_extra_stations( project_root: Path, pipes: list[PipePoint] ) -> tuple[tuple[float, str], ...]: @@ -224,18 +276,9 @@ def resolve_extra_stations( types = structure_type_map() derived: list[tuple[float, str]] = [] for pipe in pipes: - if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: + label = pipe_station_label(pipe, types) + if label is None: continue - if pipe.facility == PIPE_FACILITY_PIPE: - values = pipe.options or {} - kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) - diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM)) - label = f"{kind} D{diameter}" - else: - definition = types.get(pipe.facility) - if definition is None: - continue - label = definition.name # 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 — # 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은 # 기준 한 곳(관이 노선을 가로지르는 점형). diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 163e45ed3..140499f9b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -50,6 +50,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): void { // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). ctx.syncFacilityForm(); + // 고른 관 지점의 시설 종류를 바꿈(54-4) — 실시간 반영으로 목록 · 알약 · 횡단도가 바로 따라옴 + if (ctx.selectedPipeChainage() !== null && ctx.currentType()?.managed_by) ctx.liveCommit(); if (ctx.editingId()) { ctx.setEditingId(null); ctx.syncButtons(); diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 526138242..5190e78c2 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -16,6 +16,7 @@ from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_gra from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( prune_stale_cross_files, + relabel_pipe_stations, run_section_generation, ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions @@ -269,6 +270,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. # 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points). attach_culvert_sets(root, cross_sections) + relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4) # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). attach_wall_structures(root, cross_sections) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 41ce275ee..2eb75a1db 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -22,6 +22,13 @@ import { fetchStructureTypes, readPendingStructures, } from "../B05_Profile/B05_Profile_Api_Structures"; +import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { + attachCulvertSets, + buildCulvertSets, + registryDefaults, +} from "@util/common_util_culvert_sets"; import { attachWallSpecs, wallSpecsFrom, @@ -55,6 +62,7 @@ async function withDraftWalls( // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 // 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인). + await withDraftPipeKinds(detail, projectId); if (!drafts || !drafts.length) return withStructureAreas(detail); const types = await fetchStructureTypes().catch(() => []); const names = new Map( @@ -77,6 +85,66 @@ async function withDraftWalls( return withStructureAreas(detail); } +/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */ +const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const; +const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => + facility === "ford_bridge" + ? "ford" + : facility === "box_culvert" + ? "box" + : facility === "ford_pavement" + ? "ford_pavement" + : "culvert"; + +/** + * 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다. + * 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식 + * (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다. + */ +async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise { + const pipes = readPendingPipes(projectId); + if (!pipes?.length) return; + const sections = detail.cross_sections as unknown as Array>; + const near = (spec: Record, section: Record, at: number) => + Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M; + /** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */ + const fits = (pipe: (typeof pipes)[number], key: string, spec: Record) => + key === facilityKey(pipe.facility ?? "pipe") && + (key !== "culvert" || (spec.hidden_pipe === true) === (pipe.facility === "revetment")); + const stale = pipes.filter((pipe) => { + const here = sections.flatMap((section) => + FACILITY_KEYS.flatMap((key) => { + const spec = section[key] as Record | undefined; + return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : []; + }), + ); + return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec)); + }); + if (!stale.length) return; + const types = await fetchStructureTypes().catch(() => []); + const registry = registryDefaults(types); + for (const section of sections) { + for (const key of FACILITY_KEYS) { + const spec = section[key] as Record | undefined; + if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key]; + } + // 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 물넘이 = 없음) + // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때) + const pipe = stale.find((entry) => { + const gap = Math.abs(entry.chainage_m - Number(section.chainage_m)); + return section.structure ? gap < PIPE_MATCH_M : gap < 0.01; + }); + if (!pipe) continue; + const facility = pipe.facility ?? "pipe"; + const values = { ...registry.pipe, ...pipe.options }; + if (facility === "ford_pavement") delete section.structure; + else if (facility === "pipe") + section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; + else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility; + } + attachCulvertSets(sections, buildCulvertSets(stale, registry)); +} + /** * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. * diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index d45b00c53..acb756d91 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -243,18 +243,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } // 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이 // [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준). - deps.applyPipeOptions?.(fromChainageM, patch); const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); + const was = hit?.facility ?? "pipe"; + const facility = attributes.facility ?? was; + // 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음 + if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch); if (hit) { - const facility = hit.facility ?? "pipe"; hit.options = - mergeFacilityOptions({ facility, options: hit.options }, { facility, options: patch }) - .options ?? {}; + mergeFacilityOptions( + { facility: was, options: hit.options }, + { facility, options: patch }, + ).options ?? {}; + hit.facility = facility; } persistPipes(); section.setPipeFacilities(pipeFacilities); + if (facility !== was) { + pushMarks(); + deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`) + } }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { diff --git a/resources/tester/test_b05_pipe_station_relabel.py b/resources/tester/test_b05_pipe_station_relabel.py new file mode 100644 index 000000000..d31a94891 --- /dev/null +++ b/resources/tester/test_b05_pipe_station_relabel.py @@ -0,0 +1,74 @@ +"""54-4 — 저장으로 관 지점 종류만 바꾸면 횡단 측점 라벨(카드 제목)이 새 종류를 따라감. + +측점은 다시 안 만들어지므로 라벨은 파일에 옛 종류로 남음 — 상세 조회가 지금 관 지점으로 고침. +다른 구조물 라벨(옹벽 등)은 건드리지 않음. +""" + +import json + +from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations +from common_util.common_util_drainage_pipes import parse_pipe_points + + +def _write_points(root, points): + edits = root / "B04_PreProcess" / "drainage" / "edits" + edits.mkdir(parents=True, exist_ok=True) + (edits / "pipe_points.json").write_text(json.dumps({"points": points}), encoding="utf-8") + + +def _sections(): + return [ + {"chainage_m": 80.0}, + {"chainage_m": 85.05, "structure": "파형강관 D900"}, + {"chainage_m": 120.0, "structure": "옹벽"}, + {"chainage_m": 173.09, "structure": "세월교"}, + {"chainage_m": 258.12, "structure": "파형강관 D800"}, + ] + + +def test_relabel_follows_saved_kind(tmp_path): + _write_points( + tmp_path, + [ + {"chainage_m": 85.05, "source": "spacing", "facility": "box_culvert"}, + {"chainage_m": 120.0, "source": "user", "facility": "box_culvert"}, # 옹벽 라벨 자리 + { + "chainage_m": 173.09, + "source": "stream", + "facility": "pipe", + "options": {"pipe_kind": "흄관", "pipe_diameter_mm": 1200}, + }, + {"chainage_m": 258.12, "source": "spacing", "facility": "ford_pavement"}, + ], + ) + sections = _sections() + relabel_pipe_stations(tmp_path, sections) + labels = {s["chainage_m"]: s.get("structure") for s in sections} + assert labels == { + 80.0: None, + 85.05: "BOX암거", + 120.0: "옹벽", # 관 지점 라벨이 아닌 측점은 그대로 + 173.09: "흄관 D1200", + 258.12: None, # 물넘이포장 = 라벨 없음(측점 생성 규칙과 같음) + } + + +def test_relabel_matches_generation_rule(tmp_path): + points = [ + {"chainage_m": 85.05, "source": "spacing", "options": {"pipe_diameter_mm": 1000}}, + {"chainage_m": 173.09, "source": "stream", "facility": "ford_bridge"}, + ] + _write_points(tmp_path, points) + sections = [ + {"chainage_m": 85.05, "structure": "세월교"}, + {"chainage_m": 173.09, "structure": "BOX암거"}, + ] + relabel_pipe_stations(tmp_path, sections) + generated = dict(resolve_extra_stations(tmp_path, parse_pipe_points(points))) + assert [s["structure"] for s in sections] == [generated[85.05], generated[173.09]] + + +def test_relabel_without_file_keeps_labels(tmp_path): + sections = _sections() + relabel_pipe_stations(tmp_path, sections) + assert sections == _sections() diff --git a/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js b/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js new file mode 100644 index 000000000..92cc1c07c --- /dev/null +++ b/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js @@ -0,0 +1,211 @@ +/* 54-4 — 좌측 패널에서 같은 자리 관 지점 시설 종류를 바꿀 때 B06 횡단도 · 종단 알약 · 목록이 바로 새 종류 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4_검증) · 로그인 · 목록에 「4+5 배관 1」(85.05 m) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m544 = "단계";` + * setup → 처음 관 지점 정본(서버)을 세션 `__544_orig` 에 남김 · 4+5 관 고름 · 종단 도크 폄 + * cycle → 저장 전 네 종류 서로 바꾸기 12길(배관 → BOX → 세월교 → 물넘이 → 배관 → 세월교 → BOX → + * 물넘이 → 세월교 → 배관 → 물넘이 → BOX → 배관) — 길마다 횡단 카드 모양 · 제목 · 목록 · 알약 · 초안 칸 + * save → BOX암거로 바꾸고 [저장] → 저장 뒤 카드 · 목록 · 알약 = BOX + * reopen → (orca reload 뒤) 저장분이 BOX 로 다시 섬 · BOX → 배관 바꾸기도 바로 따라옴(저장 뒤 길) + * restore → 처음 정본을 관 초안에 되얹고 [저장] → 서버 관 지점 = 처음과 같음 + * 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__544_out_{단계}")` 로 읽음 + * 쓰는 것: 검증 프로젝트 관 지점 정본(save · restore) · sessionStorage + * 기대 수치: 모든 단계 fails 0 · restore same true + */ +(async () => { + const MODE = window.__m544 || "cycle"; + const raf = window.requestAnimationFrame; + window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const AT = 85.05; + const pid = localStorage.getItem("frd_current_project_id"); + const api = async (u) => (await fetch(u, { credentials: "include" })).json(); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const NAME = { + pipe: "배관", + box_culvert: "BOX암거", + ford_bridge: "세월교", + ford_pavement: "물넘이포장", + }; + const CHIP = { + pipe: "배수관", + box_culvert: "BOX암거", + ford_bridge: "세월교", + ford_pavement: null, + }; + /** 그 종류에만 있는 칸 — 다른 종류로 바꾼 뒤 초안에 남으면 옛 칸이 섞인 것. */ + const OWN = { + pipe: ["inlet_revet_form", "outlet_revet_form", "inlet_basin_length_m"], // 세월교도 inlet_type 을 씀(날개벽) + box_culvert: ["body_width_m", "body_height_m"], + ford_bridge: ["pipe_count"], + ford_pavement: [], + }; + const typeSel = () => + document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const row = () => rows().find((li) => /^4\+5 (배관|BOX암거|세월교|물넘이포장)/.test(txt(li))); + const card = () => + [...document.querySelectorAll(".b06-cross-card")] + .map((c) => ({ c, at: Number(txt(c).split(" ")[1]?.replace("m", "")) })) + .sort((a, b) => Math.abs(a.at - AT) - Math.abs(b.at - AT))[0]?.c; + const pill = () => + [...document.querySelectorAll(".b05-structure__mark")].find( + (m) => m.dataset.structureId === `pipe-${AT.toFixed(2)}`, + ); + const draft = () => { + const raw = sessionStorage.getItem(`aislo:draft:pipes:${pid}`); + const v = raw ? JSON.parse(raw) : null; + return (v?.value ?? v ?? []).find((p) => Math.abs(p.chainage_m - AT) < 0.51) ?? null; + }; + const openDock = async () => { + if (pill()) return; + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + if (handle?.getAttribute("aria-expanded") !== "true") handle?.click(); + for (let k = 0; k < 20 && !pill(); k++) await w(300); + }; + const pick = async () => { + for (let k = 0; k < 40 && !row(); k++) await w(500); + if (!row()?.classList.contains("is-selected")) { + row()?.click(); + await w(2500); + } + }; + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + /** 횡단 카드 그림이 그 종류 모양인가 — 클래스 · 툴팁(svg title) 첫 낱말로 가름. */ + const shapeOf = (c) => { + const svg = c.querySelector("svg"); + const cls = [...svg.querySelectorAll("[class]")].map((x) => x.getAttribute("class")); + const tips = [...svg.querySelectorAll("title")].map((x) => x.textContent); + if (tips.some((t) => t.startsWith("BOX암거"))) return "box_culvert"; + if (cls.some((k) => k.includes("ford-slab"))) return "ford_bridge"; + if (cls.some((k) => k.includes("culvert-revet") || k.includes("culvert-pipe"))) return "pipe"; + return "none"; + }; + const check = (to, label) => { + const c = card(); + const got = { + shape: c ? shapeOf(c) : "카드 없음", + chip: c ? [...c.querySelectorAll(".b06-cross-card__structure")].map(txt) : [], + notes: c ? [...c.querySelectorAll(".b06-cross-card__warning")].map(txt) : [], + row: row() ? txt(row()) : null, + pill: pill() ? `${txt(pill())}|${pill().title}` : null, + type: typeSel().value, + facility: draft()?.facility, + keys: Object.keys(draft()?.options ?? {}), + }; + const want = to === "ford_pavement" ? "none" : to; + if (got.shape !== want) fail(`${label}: 모양 ${got.shape} ≠ ${want}`); + if (CHIP[to] ? got.chip.join() !== CHIP[to] : got.chip.length) + fail(`${label}: 제목 ${got.chip}`); + if (to === "ford_pavement" && !got.notes.some((n) => n.includes("물넘이포장"))) + fail(`${label}: 물넘이 알림 없음 ${got.notes}`); + if (got.notes.some((n) => Object.values(NAME).some((v) => v !== NAME[to] && n.includes(v)))) + fail(`${label}: 옛 종류 알림 ${got.notes}`); + if (!got.row?.startsWith(`4+5 ${NAME[to]}`)) fail(`${label}: 목록 ${got.row}`); + if (!got.pill || !got.pill.includes(NAME[to])) fail(`${label}: 알약 ${got.pill}`); + if (got.type !== to) fail(`${label}: 폼 종류 ${got.type}`); + if (!["setup", "save", "reopen"].includes(MODE) && got.facility !== to) + fail(`${label}: 초안 종류 ${got.facility}`); + for (const [kind, keys] of Object.entries(OWN)) + if (kind !== to && keys.some((k) => got.keys.includes(k))) + fail(`${label}: 옛 ${kind} 칸 섞임 ${got.keys.filter((k) => keys.includes(k))}`); + return got; + }; + const change = async (to) => { + const t = typeSel(); + t.value = to; + t.dispatchEvent(new Event("change", { bubbles: true })); + await until(() => card() && shapeOf(card()) === (to === "ford_pavement" ? "none" : to)); + await w(600); + }; + const save = async () => { + [...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click(); + await until(() => document.querySelector(".ui-toast, [class*='toast']"), 60000); + await w(4000); + return document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null; + }; + const out = { mode: MODE, fails: [] }; + const fail = (what) => out.fails.push(what); + + if (MODE === "setup") { + const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + sessionStorage.setItem("__544_orig", JSON.stringify(pipes)); + out.orig = pipes.find((p) => Math.abs(p.chainage_m - AT) < 0.51); + await pick(); + await openDock(); + out.start = check(typeSel().value, "처음"); + } else if (MODE === "cycle") { + await pick(); + await openDock(); + const path = [ + "pipe", + "box_culvert", + "ford_bridge", + "ford_pavement", + "pipe", + "ford_bridge", + "box_culvert", + "ford_pavement", + "ford_bridge", + "pipe", + "ford_pavement", + "box_culvert", + "pipe", + ]; + if (typeSel().value !== path[0]) fail(`처음 종류 ${typeSel().value}`); + out.steps = []; + for (let i = 1; i < path.length; i++) { + const label = `${i} ${path[i - 1]}→${path[i]}`; + await change(path[i]); + const got = check(path[i], label); + out.steps.push( + `${label} ${got.shape} ${got.chip.join("/") || "-"} | ${got.row} | ${got.pill}`, + ); + } + out.ways = new Set(path.slice(1).map((to, i) => `${path[i]}→${to}`)).size; + } else if (MODE === "save") { + await pick(); + await openDock(); + await change("box_culvert"); + out.toast = await save(); + await openDock(); + out.after = check("box_culvert", "저장 뒤"); + const saved = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + out.server = saved.find((p) => Math.abs(p.chainage_m - AT) < 0.51)?.facility; + if (out.server !== "box_culvert") fail(`서버 종류 ${out.server}`); + } else if (MODE === "reopen") { + await pick(); + await openDock(); + out.reopened = check("box_culvert", "다시 연 BOX"); + await change("pipe"); + out.toPipe = check("pipe", "저장 뒤 BOX→배관"); + } else if (MODE === "restore") { + const orig = JSON.parse(sessionStorage.getItem("__544_orig") ?? "null"); + if (!orig) fail("처음 정본 없음 — setup 먼저"); + else { + const points = orig.map(({ lonlat, ...rest }) => rest); // 좌표는 서버가 다시 계산(DetailPipeInput) + sessionStorage.setItem(`aislo:draft:pipes:${pid}`, JSON.stringify(points)); + location.reload(); + return "reload — 다시 부르면 저장"; // 새로 고친 뒤 `__m544 = "restore2"` + } + } else if (MODE === "restore2") { + await pick(); + out.toast = await save(); + const orig = JSON.parse(sessionStorage.getItem("__544_orig")); + const now = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + const pick2 = (list) => + list.map((p) => [p.chainage_m, p.facility ?? "pipe", JSON.stringify(p.options ?? {})]); + out.same = JSON.stringify(pick2(now)) === JSON.stringify(pick2(orig)); + if (!out.same) fail("되돌린 정본이 처음과 다름"); + out.at = now.find((p) => Math.abs(p.chainage_m - AT) < 0.51); + } + window.requestAnimationFrame = raf; + out.failCount = out.fails.length; + sessionStorage.setItem(`__544_out_${MODE}`, JSON.stringify(out)); + return JSON.stringify(out); +})();