From 83791547302e3efc198ee085ec0c5c46743e21ee Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:49:15 +0900 Subject: [PATCH 01/99] =?UTF-8?q?feat(B05):=20=EC=82=AC=ED=86=A0=EC=9E=A5?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=EC=B8=A1=EC=A0=90=20=3D=20=ED=86=A0?= =?UTF-8?q?=EC=B7=A8=EC=9E=A5=EA=B3=BC=20=EA=B0=99=EC=9D=80=20=EA=BC=B4(54?= =?UTF-8?q?-7)=20=E2=80=94=20=EA=B8=B0=EC=A4=80=20=EC=B8=A1=EC=A0=90=20?= =?UTF-8?q?=ED=95=9C=20=EC=A0=90=20=C2=B7=20B06=20=EC=82=AC=ED=86=A0=20?= =?UTF-8?q?=EB=B0=B0=EB=B6=84=20=EC=9D=B4=EC=96=B4=20=EC=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 정의 사토장(spoil_bank) 배치 구간 → 부지 · 정의 판 14(이력 판_13) · 화면은 토취장 길 그대로(기준 측점 No + 거리) - 옛 구간 저장분 = 읽을 때 기준 측점(옛 기준 → 시작) · 시작 · 종료 뺌 · 파일은 다음 [저장]까지 그대로 - B06 사토 배분 = 기준 측점에 가장 가까운 측점 하나(가운데면 앞 측점) × 대표 길이(앞뒤 절반) - 바뀌는 값: 담은 양 같음 · 쌓는 측점 구간 여럿 → 하나 · 그 측점 폭 · 면적 커짐 - 시험 test_b06_spoil_fill_allocation(기준 측점 꼴 · 옛 꼴 읽기 · 가까운 측점) · test_52_9 · 화면 ui_checks/54-7 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B05_Profile/B05_Profile_Structures_Schema.py | 18 + B06_Section/B06_Section_Engine_SpoilFill.py | 75 +- resources/master_template/구조물정의.json | 4 +- .../구조물정의_이력/판_13.json | 3663 +++++++++++++++++ resources/tester/test_52_9_spoil_borrow.py | 16 + .../tester/test_b06_spoil_fill_allocation.py | 83 +- .../tester/ui_checks/54-7_사토장_기준측점.js | 173 + 7 files changed, 3983 insertions(+), 49 deletions(-) create mode 100644 resources/master_template/구조물정의_이력/판_13.json create mode 100644 resources/tester/ui_checks/54-7_사토장_기준측점.js diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index 3c37ca035..e7c8db289 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -201,6 +201,24 @@ class StructureInstance(BaseModel): def validate_group_items(cls, value: Any) -> dict[str, Any] | None: return parse_group_items(value) + @model_validator(mode="before") + @classmethod + def follow_site_definition(cls, data: Any) -> Any: + """정의가 부지형인데 옛 구간형으로 적힌 저장분(사토장 · PLAN 54-7) — 기준 측점 하나로 읽음. + + 기준 측점이 없으면 옛 기준 → 시작 차례. 파일의 옛 값은 [저장] 전까지 그대로. + """ + if not isinstance(data, dict) or data.get("placement") != "interval": + return data + definition = structure_type_map().get(str(data.get("type_id"))) + if definition is None or definition.placement != "site": + return data + chainage = data.get("chainage_m") + if chainage is None: + chainage = data.get("start_m") + rest = {key: value for key, value in data.items() if key not in ("start_m", "end_m")} + return {**rest, "placement": "site", "chainage_m": chainage} + @model_validator(mode="after") def validate_placement_fields(self) -> "StructureInstance": if self.placement == "interval": diff --git a/B06_Section/B06_Section_Engine_SpoilFill.py b/B06_Section/B06_Section_Engine_SpoilFill.py index 022961751..b07d94d27 100644 --- a/B06_Section/B06_Section_Engine_SpoilFill.py +++ b/B06_Section/B06_Section_Engine_SpoilFill.py @@ -37,32 +37,35 @@ _SIDE_AUTO = "자동(성토 쪽)" _SIDE_WORDS = {"좌": "left", "우": "right"} -def _sections_in(cross_sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[dict]: - """구간 안에 든 측점만. **새 측점을 만들지 않는다**(2026-09-09 사용자 확정 ③).""" - picked = [] - for section in cross_sections: - chainage = section.get("chainage_m") - if chainage is None: - continue - value = float(chainage) - if start_m - 1e-6 <= value <= end_m + 1e-6: - picked.append(section) - return sorted(picked, key=lambda item: float(item["chainage_m"])) +def _section_at(cross_sections: list[dict[str, Any]], chainage_m: float) -> list[dict]: + """기준 측점에 가장 가까운 측점 하나(PLAN 54-7 · 가운데면 앞 측점). - -def _spans(sections: list[dict[str, Any]], start_m: float, end_m: float) -> list[float]: - """측점마다 대표 길이(m) — 앞뒤 측점과의 절반씩. 구간 끝은 경계까지만.""" - spans: list[float] = [] - for index, section in enumerate(sections): - chainage = float(section["chainage_m"]) - left = float(sections[index - 1]["chainage_m"]) if index else max(start_m, chainage) - right = ( - float(sections[index + 1]["chainage_m"]) - if index + 1 < len(sections) - else min(end_m, chainage) + **새 측점을 만들지 않는다**(확정 ③). + """ + placed = [section for section in cross_sections if section.get("chainage_m") is not None] + if not placed: + return [] + return [ + min( + placed, + key=lambda item: ( + abs(float(item["chainage_m"]) - chainage_m), + float(item["chainage_m"]), + ), ) - spans.append(max((chainage - left) / 2 + (right - chainage) / 2, 0.0)) - return spans + ] + + +def _span_of(cross_sections: list[dict[str, Any]], section: dict[str, Any]) -> float: + """그 측점의 대표 길이(m) — 앞뒤 측점과의 절반씩. 끝 측점은 한쪽만.""" + chainages = sorted( + {float(item["chainage_m"]) for item in cross_sections if item.get("chainage_m") is not None} + ) + here = float(section["chainage_m"]) + index = chainages.index(here) + left = chainages[index - 1] if index else here + right = chainages[index + 1] if index + 1 < len(chainages) else here + return (right - left) / 2 def _has_spoil_value(design: dict[str, Any]) -> bool: @@ -92,7 +95,10 @@ def _side_of(design: dict[str, Any], option: Any) -> str | None: def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]: - """배치된 사토장만 골라 쓰기 좋은 모양으로. 구간·용량이 없으면 뺀다.""" + """배치된 사토장만 골라 쓰기 좋은 모양으로. 기준 측점·용량이 없으면 뺀다. + + 기준 측점이 없는 옛 구간 저장분은 시작 측점으로 읽음(PLAN 54-7). + """ sites: list[dict[str, Any]] = [] for item in structures: type_id = getattr(item, "type_id", None) or ( @@ -104,13 +110,13 @@ def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]: if options is None and isinstance(item, dict): options = item.get("options") options = options or {} - start = getattr(item, "start_m", None) - end = getattr(item, "end_m", None) + chainage = getattr(item, "chainage_m", None) if isinstance(item, dict): - start = item.get("start_m") - end = item.get("end_m") + chainage = item.get("chainage_m") + if chainage is None: + chainage = item.get("start_m") capacity = options.get("capacity_m3") - if start is None or end is None or capacity in (None, ""): + if chainage is None or capacity in (None, ""): continue try: capacity_value = float(capacity) @@ -122,8 +128,7 @@ def spoil_sites(structures: list[Any]) -> list[dict[str, Any]]: { "structure_id": getattr(item, "structure_id", None) or (item.get("structure_id") if isinstance(item, dict) else None), - "start_m": min(float(start), float(end)), - "end_m": max(float(start), float(end)), + "chainage_m": float(chainage), "capacity_m3": capacity_value, "side_option": options.get("side"), "slope_ratio_n": options.get("fill_slope_ratio"), @@ -223,7 +228,7 @@ def enforce_spoil_fills( # (노선 성토도 그때 원래 값으로 돌아온다 — 사토장 몫을 빼 두었기 때문이다.) covered: set[float] = set() for site in sites: - for section in _sections_in(cross_sections, site["start_m"], site["end_m"]): + for section in _section_at(cross_sections, site["chainage_m"]): covered.add(float(section["chainage_m"])) for section in cross_sections: design = section.get("design") @@ -256,10 +261,10 @@ def enforce_spoil_fills( if not sites: return changed for site in sites: - sections = _sections_in(cross_sections, site["start_m"], site["end_m"]) + sections = _section_at(cross_sections, site["chainage_m"]) if not sections: continue - spans = _spans(sections, site["start_m"], site["end_m"]) + spans = [_span_of(cross_sections, section) for section in sections] sides = [_side_of(section.get("design") or {}, site["side_option"]) for section in sections] ratio = site["slope_ratio_n"] diff --git a/resources/master_template/구조물정의.json b/resources/master_template/구조물정의.json index 687660ac0..1e88bd0d6 100644 --- a/resources/master_template/구조물정의.json +++ b/resources/master_template/구조물정의.json @@ -1,5 +1,5 @@ { - "판": 13, + "판": 14, "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`가 새 키로 옮긴다.", "구조물군": { @@ -2917,7 +2917,7 @@ "type_id": "spoil_bank", "group": "E", "name": "유용토운반작업장(사토장)", - "placement": "interval", + "placement": "site", "style": { "color": "#8c9a2e", "abbr": "사토장" diff --git a/resources/master_template/구조물정의_이력/판_13.json b/resources/master_template/구조물정의_이력/판_13.json new file mode 100644 index 000000000..687660ac0 --- /dev/null +++ b/resources/master_template/구조물정의_이력/판_13.json @@ -0,0 +1,3663 @@ +{ + "판": 13, + "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": "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", + "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": "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": "가드레일", + "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, + "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": "비우면 길이 − 전 으로 셈(길이 = 전 + 후 맞물림)" + } + ] + }, + { + "type_id": "position_sign", + "group": "E", + "name": "국가지점번호판", + "placement": "point", + "style": { + "color": "#53a832", + "abbr": "번호판" + }, + "drawing_views": [ + "plan", + "detail" + ], + "options": [] + }, + { + "type_id": "water_intake", + "group": "E", + "name": "취수정", + "placement": "point", + "style": { + "color": "#3a9e78", + "abbr": "취수정" + }, + "drawing_views": [ + "plan" + ], + "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_본체규격", + "날개벽" + ], + "이름": { + "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": { + "동작": [ + "계곡통과시설", + "월류단면" + ] + }, + "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", + "이름": "관 보호공(유출)", + "옵션": [], + "형상": {} + } + ], + "evac_road": [ + { + "id": "gevacdetail", + "이름": "상세 내용", + "옵션": [ + "detail" + ], + "형상": {} + } + ], + "guardrail": [ + { + "id": "gguarddetail", + "이름": "상세 내용", + "옵션": [ + "form", + "length_m", + "before_m", + "after_m" + ], + "형상": {} + } + ], + "cross_underdrain": [ + { + "id": "gcrossud", + "이름": "맹암거", + "옵션": [ + "culvert_height_m", + "culvert_slope_pct" + ], + "형상": {} + } + ], + "spoil_bank": [ + { + "id": "gspoildetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "borrow_pit": [ + { + "id": "gborrowdetail", + "이름": "상세 내용", + "옵션": [ + "capacity_m3" + ], + "형상": {} + } + ], + "box_culvert": [ + { + "id": "gboxbody", + "이름": "본체", + "옵션": [ + "body_width_m", + "body_height_m" + ], + "형상": {} + }, + { + "id": "gboxin", + "이름": "유입 구조", + "옵션": [ + "wing_in" + ], + "형상": {} + }, + { + "id": "gboxout", + "이름": "유출 구조", + "옵션": [ + "wing_out" + ], + "형상": {} + } + ], + "ford_pavement": [ + { + "id": "gfordpaveslope", + "이름": "경사각도", + "옵션": [], + "형상": {} + } + ], + "retaining_wall": [ + { + "id": "gretaindetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "form", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2" + ], + "형상": {} + } + ], + "masonry_wet": [ + { + "id": "gmasonwetdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "masonry_dry": [ + { + "id": "gmasondrydetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "face_role", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "soil_guard": [ + { + "id": "gsoilgdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "form", + "side", + "tiers", + "lift_m", + "shift_m", + "thickness_top_m", + "thickness_bottom_m", + "back_len_cm", + "stone_kind", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "foundation", + "weep_hole_diameter_mm", + "weep_hole_area_m2" + ], + "형상": {} + } + ], + "boulder_masonry": [ + { + "id": "gboulderdetail", + "이름": "상세 내용", + "옵션": [ + "height_m", + "length_m", + "before_m", + "after_m", + "stone_cm", + "bond", + "stone_supply", + "stone_coeff_basis", + "fill_concrete_mpa", + "face_slope_ratio", + "foundation", + "side", + "weep_hole_diameter_mm", + "weep_hole_area_m2", + "blinding_concrete" + ], + "형상": {} + } + ], + "berm": [ + { + "id": "gbermdetail", + "이름": "상세 내용", + "옵션": [ + "length_m", + "before_m", + "after_m", + "width_m", + "interval_m", + "slope_deg" + ], + "형상": {} + } + ] + }, + "배치": {}, + "규칙": [] +} diff --git a/resources/tester/test_52_9_spoil_borrow.py b/resources/tester/test_52_9_spoil_borrow.py index afbe30582..83e39ff0f 100644 --- a/resources/tester/test_52_9_spoil_borrow.py +++ b/resources/tester/test_52_9_spoil_borrow.py @@ -72,3 +72,19 @@ def test_B06_사토_배분은_부피를_그대로_읽음_옛_값도(): assert [s["capacity_m3"] for s in sites] == [150.0, 90.0] assert sites[0]["side_option"] is None and sites[1]["side_option"] == "좌" assert sites[1]["extra_distance_m"] == 5 + + +def test_54_7_사토장_기준_측점_꼴은_토취장과_같음_옛_구간은_기준_측점으로_읽음(): + from B05_Profile.B05_Profile_Structures_Schema import StructureInstance + + by_id = {t.type_id: t for t in load_structure_types()} + assert by_id["spoil_bank"].placement == by_id["borrow_pit"].placement == "site" + old = {"type_id": "spoil_bank", "placement": "interval", "start_m": 60, "end_m": 80} + for extra, station in (({"chainage_m": 70}, 70.0), ({}, 60.0)): + item = StructureInstance.model_validate({**old, **extra}) + assert (item.placement, item.chainage_m, item.start_m, item.end_m) == ( + "site", + station, + None, + None, + ) diff --git a/resources/tester/test_b06_spoil_fill_allocation.py b/resources/tester/test_b06_spoil_fill_allocation.py index fa05bc7cf..c1adda319 100644 --- a/resources/tester/test_b06_spoil_fill_allocation.py +++ b/resources/tester/test_b06_spoil_fill_allocation.py @@ -4,7 +4,8 @@ ① 용량이 커질수록 폭이 넓어지고, 상한에서 멈추면 못 담은 몫이 값으로 남는다. ② 사토장이 선 측점은 **노선 성토가 줄고** 그만큼이 사토장 몫으로 간다(두 번 세지 않기). ③ 「자동」이면 그 측점의 **성토 쪽**에 선다. - ④ 구간 밖 측점은 안 건드린다 — **새 측점을 만들지 않는다**. + ④ 기준 측점 밖 측점은 안 건드린다 — **새 측점을 만들지 않는다**. + ⑤ 사토장 = 기준 측점 한 점(PLAN 54-7) — 가장 가까운 측점 하나 · 옛 구간 저장분은 시작 측점. """ from __future__ import annotations @@ -65,9 +66,8 @@ def _write_site(root: Path, capacity_m3: float, **options) -> None: { "structure_id": "sp-1", "type_id": "spoil_bank", - "placement": "interval", - "start_m": 100.0, - "end_m": 160.0, + "placement": "site", + "chainage_m": 120.0, "options": {"capacity_m3": capacity_m3, **options}, } ], @@ -86,15 +86,15 @@ def _run(root: Path, capacity_m3: float, **options) -> list[dict]: def _placed(sections: list[dict]) -> float: - inside = [s for s in sections if 100.0 <= s["chainage_m"] <= 160.0] + inside = [s for s in sections if s["chainage_m"] == 120.0] return float(inside[0]["design"].get("spoil_fill_placed_m3") or 0.0) def test_용량이_클수록_폭이_넓어진다(tmp_path: Path) -> None: small = _run(tmp_path, 200.0) large = _run(tmp_path, 800.0) - narrow = small[1]["design"]["spoil_fill_width_m"] - wide = large[1]["design"]["spoil_fill_width_m"] + narrow = small[2]["design"]["spoil_fill_width_m"] + wide = large[2]["design"]["spoil_fill_width_m"] assert 0 < narrow < wide assert _placed(small) == pytest.approx(200.0, rel=0.02) assert _placed(large) == pytest.approx(800.0, rel=0.02) @@ -103,7 +103,7 @@ def test_용량이_클수록_폭이_넓어진다(tmp_path: Path) -> None: def test_담을_수_없으면_남은_몫이_값으로_남는다(tmp_path: Path) -> None: """상한에서 멈춘다 — 임의로 더 넓히지 않는다.""" sections = _run(tmp_path, 1_000_000.0) - design = sections[1]["design"] + design = sections[2]["design"] assert design["spoil_fill_unplaced_m3"] > 0 assert design["spoil_fill_width_m"] == pytest.approx(design["spoil_fill_max_width_m"]) @@ -123,16 +123,16 @@ def test_노선_성토가_그만큼_줄어든다(tmp_path: Path) -> None: def test_자동이면_성토_쪽에_선다(tmp_path: Path) -> None: sections = _run(tmp_path, 400.0, side="자동(성토 쪽)") # right_cut = 우측 절토 ⇒ 성토는 좌측(+offset). - assert sections[1]["design"]["spoil_fill_side"] == "left" + assert sections[2]["design"]["spoil_fill_side"] == "left" -def test_구간_밖_측점은_안_건드린다(tmp_path: Path) -> None: - """새 측점을 만들지 않고, 구간 밖은 종전 그대로다(확정 ③).""" +def test_기준_측점_밖_측점은_안_건드린다(tmp_path: Path) -> None: + """새 측점을 만들지 않고, 기준 측점 밖은 종전 그대로다(확정 ③).""" sections = _run(tmp_path, 400.0) assert len(sections) == 6 for section in sections: design = section["design"] - if 100.0 <= section["chainage_m"] <= 160.0: + if section["chainage_m"] == 120.0: assert design["spoil_fill_area_m2"] > 0 else: assert design.get("spoil_fill_area_m2", 0) == 0 @@ -186,3 +186,62 @@ def test_사토장을_지우면_값도_사라진다(tmp_path: Path) -> None: assert design.get("spoil_fill_capacity_m3") in (None, 0), section["chainage_m"] # 노선 성토도 원래 값으로 돌아온다 — 사토장 몫을 빼 두었던 것이 복구된다. assert sections[2]["design"]["fill_area_m2"] == pytest.approx(plain_fill, abs=1e-6) + + +def _write_raw(root: Path, item: dict) -> None: + (root / "B05_Profile" / "route").mkdir(parents=True, exist_ok=True) + (root / "B05_Profile" / "route" / "structures.json").write_text( + json.dumps({"revision": 1, "structures": [item]}, ensure_ascii=False), encoding="utf-8" + ) + + +def _spoiled(sections: list[dict]) -> list[float]: + return [ + s["chainage_m"] for s in sections if float(s["design"].get("spoil_fill_area_m2") or 0) > 0 + ] + + +@pytest.mark.parametrize( + ("legacy", "station"), + [ + ({"chainage_m": 140.0}, 140.0), # 옛 기준이 있으면 옛 기준 + ({}, 100.0), # 없으면 시작 + ], +) +def test_옛_구간_저장분은_옛_기준_시작_차례로_읽는다( + tmp_path: Path, legacy: dict, station: float +) -> None: + _write_raw( + tmp_path, + { + "structure_id": "sp-1", + "type_id": "spoil_bank", + "placement": "interval", + "start_m": 100.0, + "end_m": 160.0, + "options": {"capacity_m3": 200.0}, + **legacy, + }, + ) + sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0]) + enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) + assert _spoiled(sections) == [station] + + +def test_기준_측점_플러스_거리는_가장_가까운_측점에_선다(tmp_path: Path) -> None: + _write_raw( + tmp_path, + { + "structure_id": "sp-1", + "type_id": "spoil_bank", + "placement": "site", + "chainage_m": 127.0, + "options": {"capacity_m3": 200.0}, + }, + ) + sections = _sections([80.0, 100.0, 120.0, 140.0, 160.0, 180.0]) + enforce_spoil_fills(_LONGITUDINAL, sections, tmp_path) + assert _spoiled(sections) == [120.0] + # 대표 길이 = 앞뒤 측점 절반씩 20 m — 부피 = 면적 × 20 + design = sections[2]["design"] + assert design["spoil_fill_placed_m3"] == pytest.approx(design["spoil_fill_area_m2"] * 20.0) diff --git a/resources/tester/ui_checks/54-7_사토장_기준측점.js b/resources/tester/ui_checks/54-7_사토장_기준측점.js new file mode 100644 index 000000000..379f48594 --- /dev/null +++ b/resources/tester/ui_checks/54-7_사토장_기준측점.js @@ -0,0 +1,173 @@ +/* 54-7 — B05 · B06 좌측 「구조물 배치」 사토장 기준 측점 = 토취장과 같은 꼴(52-9 시나리오 고침) + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(sub7_검증) + * 앞 준비: 검증 프로젝트 structures.json 에 옛 꼴(구간 18+0~19+0 · 기준 18+10 · 부피 90) 사토장을 + * 파일로 직접 심음(API 로 보내면 서버가 새 꼴로 바꿔 적음) · 구조물 초안 비움 · orca reload + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m547 = "단계";` 를 붙여 + * edit → 옛 사토장 · 새 사토장(25+10 · 150) · 토취장(28+0 · 80) 위치 줄 꼴 · 잇기 · [저장] · 그 뒤 orca reload + * verify → 다시 연 값 — B05 에서 한 번 · B06(#/b06-section) 에서 한 번(B05 = B06) + * 되돌림: 앞 준비 때 떠 둔 structures.json 을 다시 씀 · 초안 비움 + * 기대 수치: 위치 줄 = 「기준 측점」 칸 하나(No + 거리 입력 둘) — 사토장 = 토취장 · 시작 · 종료 칸 숨김 · + * 옛 사토장 = 18+10(옛 기준) · 부피 90 · 저장 뒤 정본 = site · chainage 370 / 510 / 560 + */ +(async () => { + const MODE = window.__m547 || "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 GID = { spoil_bank: "gspoildetail", borrow_pit: "gborrowdetail" }; + const section = () => document.querySelector(".b05-structure-section"); + const selects = () => section().querySelectorAll(".b05-structure__grid select"); + const change = async (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(250); + }; + const row = () => section().querySelector(".b05-structure__position-row"); + const station = async (index, no, plus) => { + const [s, r] = row().children[index].querySelectorAll("input"); + s.value = String(no); + s.dispatchEvent(new Event("change", { bubbles: true })); + r.value = String(plus); + r.dispatchEvent(new Event("change", { bubbles: true })); + await w(250); + }; + const box = (type) => section().querySelector(`[data-b05-group="${GID[type]}"]`); + const volume = (type) => box(type)?.querySelector('[data-option-keys="capacity_m3"] input'); + const linked = (type) => + !!box(type)?.querySelector(".b05-structure__summary-name")?.classList.contains("is-assigned"); + // 위치 줄 — 보이는 칸마다 「이름 · 입력 수 · 값」 + const position = () => + [...row().children] + .filter((c) => !c.hidden && c.offsetParent !== null) + .map((c) => ({ + label: c.querySelector("span")?.textContent ?? "", + inputs: c.querySelectorAll("input").length, + value: [...c.querySelectorAll("input")].map((i) => i.value).join("+"), + })); + const shape = (type) => ({ + position: position(), + boxes: [...section().querySelectorAll("[data-b05-group]")].map((b) => b.dataset.b05Group), + volume: volume(type)?.value, + linked: linked(type), + }); + const listItem = (re) => + [...document.querySelectorAll(".b05-route__irregular-list li")].find((l) => + re.test(l.textContent.trim()), + ); + const open = async (re) => { + await until(() => listItem(re)); + const li = listItem(re); + if (!li) return false; + (li.querySelector("button") ?? li).click(); + await w(800); + if (!selects()[1].value) (li.querySelector("button") ?? li).click(); + await until(() => !!selects()[1].value); + await w(400); + return true; + }; + const pickLink = async (type) => { + box(type).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(600); + }; + const save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + return until(() => !document.querySelector(".ui-overlay.is-active"), 60000); + }; + const mine = (list) => + list + .filter((s) => s.type_id in GID) + .map((s) => ({ + type: s.type_id, + placement: s.placement, + at: [s.start_m, s.chainage_m, s.end_m], + capacity: s.options?.capacity_m3, + group_items: Object.keys(s.group_items ?? {}), + })); + const out = { mode: MODE }; + + if (MODE === "edit") { + out.listOld = listItem(/사토장/)?.textContent.trim(); + // ① 옛 꼴 사토장 — 기준 측점 하나(옛 기준 18+10) + out.openedOld = await open(/^18\+10.*사토장/); + out.old = shape("spoil_bank"); + // ② 새 사토장 기준 25+10 · 부피 150 + section().querySelector(".b05-route__irregular-actions button:last-child").click(); // 리셋 + await w(400); + await change(selects()[0], "E"); + await change(selects()[1], "spoil_bank"); + out.newSpoilForm = shape("spoil_bank"); + const anchor = [...row().children].findIndex((c) => !c.hidden && c.offsetParent !== null); + await station(anchor, 25, 10); + await change(volume("spoil_bank"), "150"); + section().querySelector(".b05-route__irregular-actions .is-primary").click(); + await w(800); + // ③ 새 토취장 28+0 · 부피 80 + section().querySelector(".b05-route__irregular-actions button:last-child").click(); + await w(400); + await change(selects()[0], "E"); + await change(selects()[1], "borrow_pit"); + out.newBorrowForm = shape("borrow_pit"); + await station(anchor, 28, 0); + await change(volume("borrow_pit"), "80"); + section().querySelector(".b05-route__irregular-actions .is-primary").click(); + await w(800); + out.sameShape = + JSON.stringify(out.newSpoilForm.position.map((p) => [p.label, p.inputs])) === + JSON.stringify(out.newBorrowForm.position.map((p) => [p.label, p.inputs])); + // ④ 잇기 + await open(/^25\+10.*사토장/); + await pickLink("spoil_bank"); + out.linkSpoil = linked("spoil_bank"); + await open(/^28\+0.*토취장/); + await pickLink("borrow_pit"); + out.linkBorrow = linked("borrow_pit"); + out.saved = await save(); + out.file = mine((await api.fetchStructures(projectId)).structures); + } + + if (MODE === "verify") { + await until(() => document.querySelectorAll(".b05-route__irregular-list li").length > 0); + const r = {}; + await open(/^18\+10.*사토장/); + r.old = shape("spoil_bank"); + await open(/^25\+10.*사토장/); + r.spoil = shape("spoil_bank"); + await open(/^28\+0.*토취장/); + r.borrow = shape("borrow_pit"); + section().querySelector(".b05-route__irregular-actions button:last-child").click(); // 리셋 + out.page = location.hash; + out.read = r; + if (location.hash.includes("b05")) sessionStorage.setItem("__547_b05", JSON.stringify(r)); + else out.sameDrawing = sessionStorage.getItem("__547_b05") === JSON.stringify(r); + } + return JSON.stringify(out); +})(); From bfe4eeb5491f3bb9f1c605757e3af09600fd8d22 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:08:26 +0900 Subject: [PATCH 02/99] =?UTF-8?q?feat(structure):=20B06=20=EB=8B=A4?= =?UTF-8?q?=EB=8B=A8=20=ED=9D=99=EB=A7=89=EC=9D=B4=20=E2=86=94=20=EB=B0=B0?= =?UTF-8?q?=EA=B4=80=20=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=EC=9E=87=EA=B8=B0(54-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단도 다단 벽 누르기 = 옛 「추가 기슭막이 (n단)」 칸 대신 그 단의 흙막이 컨테이너를 켜고 보임 - 흙막이 n 컨테이너 형태 · 높이 → 다단 n 단 조정값 · 길이 · 전 · 후 → 단별 구간값(높이 한도 · 지형 맞춤 규칙 그대로 · 한도 넘은 요청은 폼에 되돌림) - 그린 다단 형태 · 높이 → 컨테이너 빈 칸 채움(유입 · 유출 벽과 같은 규칙) - 컨테이너 수 = 지형이 세운 단 수 · 넘친 컨테이너 · 단 수 0 의 흙막이 값은 가리기만 하고 파일에 남김 - 시험 test_54_10_soil_tiers · 화면 점검 ui_checks/54-10_다단_흙막이 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- A00_Common/design/design_structure_form.ts | 16 +- .../design/design_structure_pipe_calc.ts | 16 +- A00_Common/design/design_view_cross.ts | 12 +- B06_Section/B06_Section_UI_Adjust_Dock.ts | 21 ++ .../B06_Section_UI_Cross_Structure_Panel.ts | 1 + .../B06_Section_UI_Page_Pipe_Options.ts | 98 +++++++- .../B06_Section_UI_Page_Structures_Panel.ts | 4 +- .../B06_Section_UI_Page_Structures_Spec.ts | 15 +- resources/tester/helper_54_10_soil_tiers.cjs | 216 ++++++++++++++++++ resources/tester/test_54_10_soil_tiers.py | 92 ++++++++ .../tester/ui_checks/54-10_다단_흙막이.js | 107 +++++++++ 11 files changed, 580 insertions(+), 18 deletions(-) create mode 100644 resources/tester/helper_54_10_soil_tiers.cjs create mode 100644 resources/tester/test_54_10_soil_tiers.py create mode 100644 resources/tester/ui_checks/54-10_다단_흙막이.js diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index dd0a7f38c..4adf4a6c4 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -56,7 +56,8 @@ export interface SpecFormHooks { hint?: (c: SpecContainer, item: SpecItem, state: SpecState) => string; onChange?: (c: SpecContainer, item: SpecItem) => void; /** 선택 컨테이너 개수를 밖에서 정함(54-1 배관 흙막이 = 추가 기슭막이(단)) — 수면 그만큼 늘 보임 · - * [추가] · [빼기] 없음 · 줄면 끝에서부터 빠짐 · null = 모름(저장된 것 그대로) · undefined = 손 [추가]. */ + * [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) · + * undefined = 손 [추가]. */ count?: (c: SpecContainer) => number | null | undefined; } @@ -228,15 +229,18 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return box.root; }; - /** 밖에서 정한 개수로 맞춤 — 줄면 끝 인스턴스 · 잇기를 뺌. 바뀌었으면 true. */ + /** 밖에서 정한 개수 — 모자라면 빈 인스턴스를 늘림(늘었으면 true) · 넘친 인스턴스 · 잇기는 + * 가리기만 하고 값에 남김(54-10 · 파일에 그대로). */ const fit = (c: SpecContainer, list: Instance[]): boolean => { const n = hooks.count?.(c); - if (c.required || n === undefined || n === null || n === list.length) return false; - for (let k = n + 1; k <= list.length; k++) links?.set(linkOf(c, k), null); - list.splice(n); + if (c.required || n === undefined || n === null || n <= list.length) return false; while (list.length < n) list.push({}); return true; }; + const shown = (c: SpecContainer, list: Instance[]): Instance[] => { + const n = c.required ? null : hooks.count?.(c); + return n === undefined || n === null ? list : list.slice(0, n); + }; function paint(quiet = true): void { rows.length = 0; @@ -245,7 +249,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { for (const c of spec?.containers ?? []) { const list = (state[c.id] ??= c.required ? [{}] : []); if (fit(c, list)) fitted.push(c); - list.forEach((values, index) => boxes.push(instanceBox(c, values, index))); + shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index))); if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue; const add = document.createElement("button"); add.type = "button"; diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index cf83b8aaa..6e60c27bf 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -5,7 +5,7 @@ * 셈 · 시험 test_b06_pipe_length) 그대로 — 여기서 새로 셈하지 않음 · 화면에 보이기만(저장 안 함). * 높이 = 손 입력 · 횡단도 조정 높이는 B06 `withSpecDefaults` 가 같은 키(`*_revet_height_m`)에 실음 · * 빈 칸 안내 = 관경 기준 최소 높이(`pipeWallMinHeightM` ↔ 파이썬 `pipe_wall_min_height_m` 짝). - * 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) — 읽기만. + * 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」(횡단 설계 patch `extra_wall_counts` · 54-1) 중 지형이 세운 단(54-10) — 읽기만. * ========================================================================== */ import { pipeWallMinHeightM } from "../../common_util/common_util_culvert_sets"; @@ -51,11 +51,17 @@ export function pipeLengthM(chainageM: number | null): number | null { return typeof length === "number" && length > 0 ? length : null; } -/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 — 횡단 자료가 없으면 null(모름). */ +/** 흙막이 개수 = 주인 측점 유출 「추가 기슭막이(단)」 중 지형이 세운 단(54-10) — 횡단 자료가 없으면 + * null(모름). 저장 단 수가 더 커도 넘친 단은 컨테이너를 안 세움(값은 파일에 그대로). */ export function pipeExtraCount(chainageM: number | null): number | null { - const owner = ownerSection(chainageM)?.owner; - if (!owner) return null; - return extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0; + const found = ownerSection(chainageM); + if (!found) return null; + const { owner, sections } = found; + const outlet = extraCountSource?.(owner) ?? owner.design?.extra_wall_counts?.outlet ?? 0; + if (!outlet || !owner.design) return outlet; + const counts = { basin: 0, ...owner.design.extra_wall_counts, outlet }; + const probe = { ...owner, design: { ...owner.design, extra_wall_counts: counts } }; + return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet; } /** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */ diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index e98171105..089f92287 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -41,7 +41,7 @@ import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick"; import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View"; -import { revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; +import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const"; import { applyPipeOptionsToCache, replayDraftPipeOptions, @@ -73,7 +73,11 @@ import { createStandardSlot, } from "../../B06_Section/B06_Section_UI_Page_Panel"; import type { DesignPageHost } from "./design_page"; -import { setPipeExtraCountSource, setPipeSectionsSource } from "./design_structure_pipe_calc"; +import { + pipeExtraCount, + setPipeExtraCountSource, + setPipeSectionsSource, +} from "./design_structure_pipe_calc"; import { bindDesignView, bindPipeOptions, @@ -152,6 +156,7 @@ export async function mountCrossView( // 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음 // (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」). if (adjust?.h == null) return null; + if (role !== "inlet" && role !== "outlet") return adjust.h; // 다단 = 기하가 되받은 적용 높이(54-10) return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m) .pureHeight; }, @@ -164,6 +169,7 @@ export async function mountCrossView( const spec = role === "outlet" ? culvert.outlet : culvert.inlet; const adjust = stationControls.revetOffset.adjustFor(owner, role); if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④) + if (role !== "inlet" && role !== "outlet") return revetFormOf(adjust.m); // 다단(54-10) return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form; }, // 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다. @@ -332,6 +338,8 @@ export async function mountCrossView( ford: stationControls.ford, box: stationControls.box, revetOffset: stationControls.revetOffset, + structureSpan: stationControls.structureSpan, + soilTiers: pipeExtraCount, overrideOptions: (chainageM, values) => structuresPanel.overrideOptions(chainageM, values), refreshCard: (chainageM) => sectionView.refreshCard(chainageM), }; diff --git a/B06_Section/B06_Section_UI_Adjust_Dock.ts b/B06_Section/B06_Section_UI_Adjust_Dock.ts index 83e8ec038..c03d8bade 100644 --- a/B06_Section/B06_Section_UI_Adjust_Dock.ts +++ b/B06_Section/B06_Section_UI_Adjust_Dock.ts @@ -145,6 +145,27 @@ function relocateSideRows(panelRoot: HTMLElement): void { const group = target.closest("fieldset"); if (!group) return; const ownerSlot = slotOf(panelRoot.dataset.extraSide === "inlet" ? "inlet" : "outlet"); + // 배관 유출 다단 = 그 단의 「흙막이」 컨테이너(54-10) — 옛 추가 기슭막이 칸 대신 그 상자를 켬 · + // 단 수 · 옵션은 부모(유출) 칸에 그대로 + const soil = + ownerSlot === sideSlots.outlet && slotUsable(ownerSlot) + ? ownerSlot + .closest(".b05-structure__spec") + ?.querySelectorAll('fieldset[data-spec-container="soil_add"]')[ + Number(panelRoot.dataset.extraIndex) + ] + : undefined; + if (soil) { + moveRows( + panelRoot, + SIDE_ROWS.replace(".b06-structure-panel__struct--readout, ", ""), + ownerSlot, + ); + setGroupLabel(soil, ""); + soil.classList.add("is-active"); + soil.scrollIntoView({ block: "nearest" }); + return; + } const owner = ownerSlot.closest("fieldset"); if (owner && owner !== group) owner.after(group); group.hidden = false; diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index c9becd983..3eb0fe8a4 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -588,6 +588,7 @@ export function buildStructurePanel( // 이름 자체로(2026-08-30 사용자 지시 1). 어느 구조물을 만지는지 폼에서 읽힌다. // 다단은 칸 이름이 이미 "추가 기슭막이"라 단 번호만 붙인다 — "추가 기슭막이 (2단)". root.dataset.panelTitle = isExtra ? `${extraIndex + 1}단` : (title.textContent ?? ""); + root.dataset.extraIndex = isExtra ? String(extraIndex) : ""; // 흙막이 컨테이너 차례(54-10) const tiers = tierChannel === "outlet" ? deps.extraState() diff --git a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts index 1d3c8d98f..063244321 100644 --- a/B06_Section/B06_Section_UI_Page_Pipe_Options.ts +++ b/B06_Section/B06_Section_UI_Page_Pipe_Options.ts @@ -12,7 +12,15 @@ import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls"; -import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const"; +import { + applySpanPatch, + EXTRA_WALL_MIN_HEIGHT_M, + REVET_EMBED_DEPTH_M, + revetFormOf, + revetHeightLimit, + revetWallSpec, +} from "./B06_Section_UI_Cross_Culvert_Const"; +import type { CrossSection } from "./B06_Section_Api_Fetch"; import type { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; type StationControls = ReturnType; @@ -23,6 +31,10 @@ export interface PipeOptionsContext { ford: StationControls["ford"]; box: StationControls["box"]; revetOffset: StationControls["revetOffset"]; + /** 다단 단별 구간값 — 흙막이 컨테이너 길이 · 전 · 후(54-10). */ + structureSpan?: StationControls["structureSpan"]; + /** 흙막이 컨테이너 수 = 지형이 세운 유출 다단 수(`pipeExtraCount` · 54-10) — 없으면 다단 안 만짐. */ + soilTiers?: (chainageM: number) => number | null; /** 기하가 잘라 낸 실제 값을 폼에 되돌린다. */ overrideOptions: (chainageM: number, values: Record) => void; refreshCard: (chainageM: number) => void; @@ -129,6 +141,7 @@ export function applyPipeOptionsToCache( ); } } + if (!culvert.hidden_pipe) applySoilTiers(ctx, owner, patch, whole); 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")); @@ -149,6 +162,89 @@ export function applyPipeOptionsToCache( } } +/** 흙막이 컨테이너가 마지막으로 보낸 높이 · 형태(`측점:extra{n}`) — 기하가 다시 그릴 때마다 키를 지형에 + * 맞춰 고쳐 되받으므로(syncApplied) 되받은 값과 견주면 안 바꾼 단도 다시 보냄(54-10). */ +const soilSent = new Map(); + +/** + * 흙막이 n 컨테이너(`soil_add_{n}_*`) → 유출 다단 n 단(`extra{n-1}`) 그림(54-10). 형태 · 높이 = 조정값 + * (m · h · 빈 칸 = 자동) · 길이 · 전 · 후 = 단별 구간값(빈 칸 = 그대로). 지형이 세운 단까지만 — + * 넘친 컨테이너 값은 파일에만 남음. 높이 = 요청 · 서는 키는 기하 규칙 그대로(지형 맞춤 · 형태 한계) · + * 형태 한계 · 하한을 넘은 요청만 폼에 되돌림. + */ +function applySoilTiers( + ctx: PipeOptionsContext, + owner: CrossSection, + patch: Record, + whole: boolean, +): void { + const culvert = owner.culvert!; + const ownerForm = revetWallSpec( + culvert.outlet, + ctx.revetOffset.adjustFor(owner, "outlet"), + false, + culvert.diameter_m, + ).form; + const value = (n: number, item: string): number | undefined => { + const raw = patch[`soil_add_${n}_${item}`]; + return raw === undefined || raw === "" || !Number.isFinite(Number(raw)) + ? undefined + : Number(raw); + }; + const blank = whole ? null : undefined; + const clipped: Record = {}; + for (let n = 1; n <= (ctx.soilTiers?.(owner.chainage_m) ?? 0); n++) { + const role = `extra${n - 1}` as const; + const key = `${owner.chainage_m.toFixed(2)}:${role}`; + const form = patch[`soil_add_${n}_form`]; + const want = { + h: value(n, "height") ?? blank, + m: typeof form === "string" && form ? form : blank, + }; + if (want.h != null) { + const limit = revetHeightLimit( + revetFormOf(want.m ?? ctx.revetOffset.adjustFor(owner, role).m, ownerForm), + ); + const h = Math.min( + Math.max(want.h, EXTRA_WALL_MIN_HEIGHT_M, REVET_EMBED_DEPTH_M + 0.1), + limit, + ); + if (Math.abs(h - want.h) > 0.05) { + clipped[`soil_add_${n}_height`] = Number(h.toFixed(1)); + showToast( + `흙막이 ${n} 높이는 ${h < want.h ? "형태 한계로" : "최소"} ${h.toFixed(1)}m` + + `${h < want.h ? "까지만" : "로"} 적용됩니다.`, + "error", + ); + } + want.h = Number(h.toFixed(1)); + } + const sent = soilSent.get(key) ?? ctx.revetOffset.adjustFor(owner, role); + if ( + (want.h !== undefined && want.h !== sent.h) || + (want.m !== undefined && want.m !== sent.m) + ) { + ctx.revetOffset.update(owner.chainage_m, role, want); + soilSent.set(key, { h: want.h ?? sent.h, m: want.m ?? sent.m }); + } + const span = { + lengthM: value(n, "length"), + beforeM: value(n, "before"), + afterM: value(n, "after"), + }; + if (!ctx.structureSpan || Object.values(span).every((v) => v === undefined)) continue; + const current = ctx.structureSpan.tierValuesFor(owner, role); + const next = applySpanPatch(current, span); + if ( + (["lengthM", "beforeM", "afterM"] as const).some((k) => Math.abs(next[k] - current[k]) > 1e-6) + ) + ctx.structureSpan.updateTier(owner, role, span); + } + // 폼에 되돌림은 이 변경을 패널이 옵션에 담은 뒤 — 지금 되돌리면 옛 옵션으로 폼을 다시 세움 + if (Object.keys(clipped).length) + queueMicrotask(() => ctx.overrideOptions(owner.chainage_m, clipped)); +} + /** 조정창(기슭막이 조정값)이 정본인 칸 — 초안에서 되살리지 않는다. */ const ADJUST_OWNED = /_revet_(height_m|form)$/; diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 0002fef70..b9d9450d4 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -78,9 +78,9 @@ export interface B06StructuresPanelDeps { detail?: () => SectionDetailResponse | null; /** 지금 그려진 벽 높이(m) — 배관 벽은 스펙이 아니라 기하가 정하므로 폼 기본 * 높이도 이 값을 쓴다(조정창 표시값과 같다). */ - wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null; + wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null; /** 지금 그려진 벽 형태 — 조정값(m)이 있으면 그쪽이 정본이라 폼도 이 값을 보인다. */ - wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null; + wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null; /** 그 측점에서 유입·유출 벽이 각각 좌·우 어느 쪽인가(독립 기슭막이 칸 이름표). */ wallSideLabels?: (chainageM: number) => { inlet: "좌" | "우"; outlet: "좌" | "우" } | null; /** 측점 간격(m) — 측점번호+잔여거리 환산용. */ diff --git a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts index e94db0ef9..00e4c49d5 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Spec.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Spec.ts @@ -140,8 +140,8 @@ export function withSpecDefaults( detail: SectionDetailResponse | null, chainageM: number, types: StructureType[], - wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null, - wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null, + wallHeight?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => number | null, + wallForm?: (chainageM: number, role: "inlet" | "outlet" | `extra${number}`) => string | null, ): Merged | undefined { const owner = detail?.cross_sections.find( (section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M, @@ -160,5 +160,16 @@ export function withSpecDefaults( const drawnHeight = wallHeight?.(chainageM, prefix); if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1)); } + // 다단 n 단 조작값 → 흙막이 n 컨테이너(54-10) — 앞 컨테이너가 비었으면 자리만 둠(차례 지킴) + for (let i = 0; i < 9 && !culvert.hidden_pipe; i++) { + const form = wallForm?.(chainageM, `extra${i}`); + const height = wallHeight?.(chainageM, `extra${i}`); + if (!form && height == null) continue; + for (let n = 1; n <= i; n++) + if (!Object.keys(merged).some((key) => key.startsWith(`soil_add_${n}_`))) + merged[`soil_add_${n}_structure`] = ""; + if (form) merged[`soil_add_${i + 1}_form`] = form; + if (height != null) merged[`soil_add_${i + 1}_height`] = Number(height.toFixed(1)); + } return merged; } diff --git a/resources/tester/helper_54_10_soil_tiers.cjs b/resources/tester/helper_54_10_soil_tiers.cjs new file mode 100644 index 000000000..a2d7ae6db --- /dev/null +++ b/resources/tester/helper_54_10_soil_tiers.cjs @@ -0,0 +1,216 @@ +/* 다단 흙막이 ↔ 흙막이 컨테이너(PLAN 54-10) 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * ① 폼 → 다단 그림(`applyPipeOptionsToCache`) ② 밖에서 정한 개수보다 많은 컨테이너 값은 가리기만(`createSpecForm`). + * 결과는 표준출력에 JSON 한 벌. 짝 = `test_54_10_soil_tiers.py`. + */ +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(); + +// ── 화면 그릇 흉내 — 태그 · 클래스 · data 만 ───────────────────────────────── +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.style = {}; + this._classes = new Set(); + this._text = ""; + this.value = ""; + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._classes.add(c)), + remove: (...n) => n.forEach((c) => self._classes.delete(c)), + toggle: (c, f) => + (f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c), + contains: (c) => self._classes.has(c), + }; + } + set className(v) { + this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent ?? "").join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k)); + } + replaceChildren(...kids) { + this.children = []; + this.append(...kids); + } + add(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this[n] = v; + } + addEventListener() {} + closest() { + return null; + } + matches(sel) { + return sel.split(",").some((s) => { + s = s.trim(); + return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase(); + }); + } + querySelectorAll(sel) { + const found = []; + const walk = (n) => + (n.children ?? []).forEach((k) => { + if (k.matches?.(sel)) found.push(k); + walk(k); + }); + walk(this); + return found; + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { createElement: (t) => new FakeNode(t) }; +globalThis.queueMicrotask = (fn) => fn(); // 폼 되돌림을 바로 받아 봄 +globalThis.Option = function (text, value) { + const o = new FakeNode("option"); + o.textContent = text; + o.value = value; + return o; +}; + +const toasts = []; +const STUBS = { + "@ui/ui_template_elements": { showToast: (msg) => toasts.push(msg) }, + "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./B06_Section_UI_Page_Ford_Controls": { + applyFordFormOptions: () => {}, + applyBoxFormOptions: () => {}, + }, + "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + return loadTs(path.join(path.dirname(full), `${request}.ts`)); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const out = {}; + +// ── ① 폼 → 다단 그림 ────────────────────────────────────────────────────── +const { applyPipeOptionsToCache } = loadTs( + path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Pipe_Options.ts"), +); +const adjusts = {}; +const updates = []; +const tiers = []; +const overrides = []; +const section = { + chainage_m: 100, + culvert: { diameter_m: 1, hidden_pipe: false, inlet: {}, outlet: { revet_form: "돌쌓기(찰)" } }, +}; +const ctx = { + detail: () => ({ cross_sections: [section] }), + ford: {}, + box: {}, + revetOffset: { + adjustFor: (_s, role) => adjusts[role] ?? { x: 0, d: null, h: null, m: null }, + update: (_c, role, patch) => { + updates.push({ role, ...patch }); + const now = adjusts[role] ?? { x: 0, d: null, h: null, m: null }; + const m = patch.m === undefined ? now.m : patch.m; + // 기하 되받기(syncApplied) 흉내 — 형태 한계로 자름(돌쌓기(메) 2.0) + const h = + patch.h == null + ? patch.h === undefined + ? now.h + : null + : Math.min(patch.h, m === "돌쌓기(메)" ? 2.0 : 5.0); + adjusts[role] = { ...now, h, m }; + }, + }, + structureSpan: { + tierValuesFor: () => ({ lengthM: 10, beforeM: 5, afterM: 5 }), + updateTier: (_s, key, patch) => tiers.push({ key, ...patch }), + }, + soilTiers: () => 2, + overrideOptions: (_c, values) => overrides.push(values), + refreshCard: () => {}, +}; +applyPipeOptionsToCache(ctx, 100, { + soil_add_1_form: "콘크리트", + soil_add_1_height: 1.8, + soil_add_1_length: 8, + soil_add_1_before: 3, + soil_add_1_after: 5, + soil_add_2_form: "돌쌓기(메)", + soil_add_2_height: 2.5, + soil_add_3_height: 1.2, // 지형이 못 세운 단 — 그림에 안 감 +}); +out.filled = { updates: updates.splice(0), tiers: tiers.splice(0), overrides: overrides.splice(0) }; +out.toasts = toasts.splice(0); +// 같은 값을 다시 보내면 다시 그리지 않음 +applyPipeOptionsToCache(ctx, 100, { + soil_add_1_form: "콘크리트", + soil_add_1_height: 1.8, + soil_add_2_form: "돌쌓기(메)", + soil_add_2_height: 2.0, +}); +out.same = { updates: updates.splice(0), tiers: tiers.splice(0) }; +// 비운 칸 = 자동(높이 · 형태) · 구간값은 그대로 +applyPipeOptionsToCache(ctx, 100, {}); +out.cleared = { updates: updates.splice(0), tiers: tiers.splice(0) }; +// 독립 기슭막이(관 숨김)는 다단을 안 만짐 +section.culvert.hidden_pipe = true; +applyPipeOptionsToCache(ctx, 100, { soil_add_1_height: 1.0 }); +out.hidden = updates.splice(0); + +// ── ② 개수보다 많은 컨테이너 값은 가리기만 · 값은 남김 ─────────────────────── +const { createSpecForm } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"), +); +const { PIPE_SPEC } = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe.ts")); +let count = 1; +const form = createSpecForm(null, { count: (c) => (c.id === "soil_add" ? count : undefined) }); +form.write(PIPE_SPEC, { + pipe_diameter_mm: 1000, + soil_add_1_form: "콘크리트", + soil_add_2_form: "돌망태", + soil_add_2_height: 1.5, +}); +const boxes = () => + form.root.children.filter((k) => k.dataset?.specContainer === "soil_add").length; +const soilKeys = () => + Object.keys(form.read()) + .filter((k) => k.startsWith("soil_add_")) + .sort(); +out.form = { shown1: boxes(), keys1: soilKeys() }; +count = 0; // 단 수 0 저장 — 옛 흙막이 값도 남음 +form.recount(); +out.form.shown0 = boxes(); +out.form.keys0 = soilKeys(); +count = 3; +form.recount(); +out.form.shown3 = boxes(); +out.form.keys3 = soilKeys(); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_54_10_soil_tiers.py b/resources/tester/test_54_10_soil_tiers.py new file mode 100644 index 000000000..b5a3b4d51 --- /dev/null +++ b/resources/tester/test_54_10_soil_tiers.py @@ -0,0 +1,92 @@ +"""다단 흙막이 ↔ 흙막이 컨테이너 (PLAN 54-10). + +짝: `B06_Section/B06_Section_UI_Page_Pipe_Options.ts`(컨테이너 값 → 다단 조정값 · 단별 구간값) · +`A00_Common/design/design_structure_form.ts`(개수보다 많은 컨테이너는 가리기만 · 값은 남김) · +`B06_Section/B06_Section_UI_Adjust_Dock.ts`(다단 벽 고르기 → 그 단의 흙막이 컨테이너). +실제 TS 를 `helper_54_10_soil_tiers.cjs` 가 Node 로 돌린다. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +HERE = os.path.dirname(__file__) +ROOT = Path(HERE).resolve().parents[1] + + +@pytest.fixture(scope="module") +def out() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", os.path.join(HERE, "helper_54_10_soil_tiers.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def extras(updates: list[dict]) -> list[dict]: + return [u for u in updates if u["role"].startswith("extra")] + + +def test_컨테이너_형태_높이가_그_단_조정값으로(out) -> None: + assert extras(out["filled"]["updates"]) == [ + {"role": "extra0", "h": 1.8, "m": "콘크리트"}, + {"role": "extra1", "h": 2.0, "m": "돌쌓기(메)"}, + ] + + +def test_지형이_못_세운_단은_그림에_안_감(out) -> None: + roles = {u["role"] for u in out["filled"]["updates"]} + assert "extra2" not in roles + + +def test_높이_한도는_형태_규칙_그대로_폼에_되돌림(out) -> None: + assert out["filled"]["overrides"] == [{"soil_add_2_height": 2}] + assert out["toasts"] == ["흙막이 2 높이는 형태 한계로 2.0m까지만 적용됩니다."] + + +def test_길이_전_후는_단별_구간값(out) -> None: + assert out["filled"]["tiers"] == [{"key": "extra0", "lengthM": 8, "beforeM": 3, "afterM": 5}] + + +def test_같은_값은_다시_그리지_않음(out) -> None: + assert extras(out["same"]["updates"]) == [] + assert out["same"]["tiers"] == [] + + +def test_비운_칸은_자동_구간값은_그대로(out) -> None: + assert extras(out["cleared"]["updates"]) == [ + {"role": "extra0", "h": None, "m": None}, + {"role": "extra1", "h": None, "m": None}, + ] + assert out["cleared"]["tiers"] == [] + + +def test_독립_기슭막이는_다단을_안_만짐(out) -> None: + assert extras(out["hidden"]) == [] + + +def test_개수보다_많은_컨테이너는_가리고_값은_남김(out) -> None: + form = out["form"] + kept = ["soil_add_1_form", "soil_add_2_form", "soil_add_2_height"] + assert (form["shown1"], form["keys1"]) == (1, kept) + assert (form["shown0"], form["keys0"]) == (0, kept) # 단 수 0 저장에도 남음 + assert form["shown3"] == 3 and form["keys3"][:3] == kept + + +def test_다단_벽_고르기는_흙막이_컨테이너로() -> None: + dock = (ROOT / "B06_Section" / "B06_Section_UI_Adjust_Dock.ts").read_text(encoding="utf-8") + assert 'fieldset[data-spec-container="soil_add"]' in dock + assert "panelRoot.dataset.extraIndex" in dock + panel = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Structure_Panel.ts").read_text( + encoding="utf-8" + ) + assert "root.dataset.extraIndex" in panel diff --git a/resources/tester/ui_checks/54-10_다단_흙막이.js b/resources/tester/ui_checks/54-10_다단_흙막이.js new file mode 100644 index 000000000..1e1a7c197 --- /dev/null +++ b/resources/tester/ui_checks/54-10_다단_흙막이.js @@ -0,0 +1,107 @@ +/* 54-10 — B06 다단 흙막이 ↔ 배관 「흙막이」 컨테이너 + * 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18) + * 부르는 법: orca eval --expression "window.__m5410={mode:'단계', n:0, item:'', value:''}; $(cat 이 파일)" + * look → 흙막이 컨테이너 수 · 켜진 상자 · 옛 「추가 기슭막이」 칸 · 다단 벽 그림(형태 · 높이) + * wall n → 횡단도 다단 n 번째(0 부터) 벽을 누름 → look + * set n item value → 흙막이 n+1 컨테이너 item(form · height · length · before · after) 값 바꿈 → look + * tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - + * 기대: wall n 뒤 켜진 상자 = n · 옛 칸 0 · set form → 벽 형태 · set height → 벽 H(한도 안) + */ +(async () => { + const ARGS = window.__m5410 || { mode: "look" }; + const PIPE = ARGS.pipe || "배관 2"; + 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 visible = (n) => n.offsetParent !== null && !n.closest("[hidden]"); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const soils = () => [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])]; + const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const station = () => + items() + .find((l) => l.textContent.trim().endsWith(PIPE)) + ?.textContent.trim() + .split(PIPE)[0]; + const card = () => + [...document.querySelectorAll(".b06-cross-card")].find((c) => + c.textContent.startsWith(station()), + ); + const pickPipe = async () => { + await until(() => items().length > 0); + const chosen = document.querySelector(".b05-route__irregular-list li.is-selected"); + if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return; + const li = items().find((l) => l.textContent.trim().endsWith(PIPE)); + (li.querySelector("button") ?? li).click(); + await until(() => spec(), 5000); + await w(800); + }; + const tierRow = () => + spec() + ?.querySelector('[data-spec-container="outlet"]') + ?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)"); + const extraShapes = () => + [...(card()?.querySelectorAll(".b06-chart__culvert-revet") ?? [])] + .map((s) => s.querySelector("title")?.textContent ?? "") + .filter((t) => t.startsWith("추가")) + .map((t) => /^(.*? H=[\d.]+m)/.exec(t)?.[1] ?? t.slice(0, 40)); + const look = () => { + const boxes = soils(); + const rect = (b) => b.getBoundingClientRect(); + return { + station: station(), + soilCount: boxes.length, + active: boxes + .map((b, i) => (b.classList.contains("is-active") ? i : -1)) + .filter((i) => i >= 0), + activeInView: boxes + .filter((b) => b.classList.contains("is-active")) + .map((b) => rect(b).top >= 0 && rect(b).bottom <= innerHeight), + values: boxes.map((b) => + Object.fromEntries( + [...b.querySelectorAll("[data-spec-item]")].map((n) => [ + n.dataset.specItem.split(".")[1], + (n.querySelector("select, input, output") ?? {}).value ?? "", + ]), + ), + ), + extraGroupShown: [...document.querySelectorAll("legend")].filter( + (l) => visible(l) && l.textContent.startsWith("추가 기슭막이"), + ).length, + tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null, + walls: extraShapes(), + }; + }; + await pickPipe(); + const out = { mode: ARGS.mode, pipe: PIPE }; + if (ARGS.mode === "wall") { + const hits = [...(card()?.querySelectorAll(".b06-chart__culvert-revet-hit") ?? [])].filter( + (h) => h.querySelector("title")?.textContent.startsWith("추가"), + ); + out.hits = hits.length; + hits[ARGS.n]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await w(1500); + } + if (ARGS.mode === "set") { + const field = soils()[ARGS.n]?.querySelector(`[data-spec-item="soil_add.${ARGS.item}"]`); + const el = field?.querySelector("select, input"); + out.found = !!el; + if (el) { + el.value = ARGS.value; + el.dispatchEvent(new Event("change", { bubbles: true })); + } + await w(1500); + } + if (ARGS.mode === "tier+" || ARGS.mode === "tier-") { + const btns = [...(tierRow()?.querySelectorAll("button") ?? [])]; + btns.find((b) => b.textContent === (ARGS.mode === "tier+" ? "+" : "-"))?.click(); + await w(1500); + } + Object.assign(out, look()); + return JSON.stringify(out); +})(); From 14965f811b8087b3b240d4b204f6ef717fbe6bd1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:26:43 +0900 Subject: [PATCH 03/99] =?UTF-8?q?fix(M02):=20=EC=A7=91=EA=B3=84=ED=91=9C?= =?UTF-8?q?=20=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=A4=84=EC=9D=80=20=EC=84=A0?= =?UTF-8?q?=20=EB=8B=A4=EB=8B=A8=20=EC=88=98=EA=B9=8C=EC=A7=80=EB=A7=8C(54?= =?UTF-8?q?-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배관 흙막이 컨테이너 줄 = 서버가 [저장] 때 세운 유출 다단 수(design.extra_walls)까지 · 넘친 상자 값은 파일에 그대로 · 단 수를 늘리면 되살아남 - 집계 항목 미지정 알림도 같은 수로 셈(같은 뿌리) - 다단 목록 없는 옛 측점 = 저장된 것 전부(옛 동작) - 시험 test_52_1b_pipe_links 둘 더 · 화면 점검 ui_checks/54-11_흙막이_집계 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- .../M02_MasterTemplete_Router_Layers.py | 8 +- M02_MasterTemplete/M02_Table_Fill.py | 5 +- M02_MasterTemplete/M02_Table_Fill_Spec.py | 16 ++ resources/tester/test_52_1b_pipe_links.py | 39 +++++ .../tester/ui_checks/54-11_흙막이_집계.js | 138 ++++++++++++++++++ 5 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 resources/tester/ui_checks/54-11_흙막이_집계.js diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index f6f44ca31..0baf219ef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -32,6 +32,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs logger = logging.getLogger(__name__) @@ -595,7 +596,10 @@ async def filled_table( raise _bad(error) from error if found is None: raise HTTPException(status_code=404, detail="양식을 찾을 수 없습니다.") - lengths = fill.pipe_lengths_from_designs(await _cross_designs(project_id)) - document = await asyncio.to_thread(fill.fill_table, project["root"], found["문서"], lengths) + designs = await _cross_designs(project_id) + lengths = fill.pipe_lengths_from_designs(designs) + document = await asyncio.to_thread( + fill.fill_table, project["root"], found["문서"], lengths, soil_tiers_from_designs(designs) + ) result = await asyncio.to_thread(fill.recalc, document) return {"이름": name, "판": found["판"], "문서": document, "결과": result} diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 862138d91..4d41ea46b 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -85,6 +85,7 @@ def collect_items( project_root: str | Path, pipe_lengths: dict[float, float] | None = None, types: dict[str, StructureType] | None = None, + soil_tiers: dict[float, int] | None = None, ) -> list[dict[str, Any]]: """설계 정본 → 구조물 목록. @@ -133,6 +134,7 @@ def collect_items( "end_m": None, "options": _with_defaults(types.get(type_id), dict(point.options or {})), "관연장": _nearest(lengths, chainage) if type_id == "pipe" else None, + "흙막이단": _nearest(soil_tiers or {}, chainage), # 54-11 "summary_item": point.summary_item, "복사본": f"pipe@{round(chainage, 3)}", "group_items": point.group_items, @@ -672,10 +674,11 @@ def fill_table( project_root: str | Path, document: dict[str, Any], pipe_lengths: dict[float, float] | None = None, + soil_tiers: dict[float, int] | None = None, ) -> dict[str, Any]: """프로젝트 설계로 표 문서를 채움 — 저장 안 함 · 지정 항목 모드(미지정은 빈칸).""" types = structure_type_map() - items = collect_items(project_root, pipe_lengths, types) + items = collect_items(project_root, pipe_lengths, types, soil_tiers) doc, chosen, notes = assign_items(project_root, document, items) filled = fill_document(doc, chosen, types, assigned=True) filled["알림"] = [*notes, *filled.get("알림", [])] diff --git a/M02_MasterTemplete/M02_Table_Fill_Spec.py b/M02_MasterTemplete/M02_Table_Fill_Spec.py index 6523e0926..bda74e9a9 100644 --- a/M02_MasterTemplete/M02_Table_Fill_Spec.py +++ b/M02_MasterTemplete/M02_Table_Fill_Spec.py @@ -7,6 +7,7 @@ 짝: `A00_Common/design/design_structure_pipe.ts` · `_road.ts` 선택 컨테이너(머리 · 잇기 · 제목) — 시험이 대조. 기타(52-14) = 사용자가 적은 상세 종류 이름이 집계 건 이름(`그룹`) · 비면 「기타」. +배관 흙막이 = 지형이 세운 유출 다단 수(`흙막이단`)까지만 — 넘친 컨테이너 값은 파일에만(54-11). """ from __future__ import annotations @@ -35,6 +36,18 @@ def etc_label(type_id: str, options: dict[str, Any] | None) -> dict[str, str]: return {"그룹": str((options or {}).get("name") or "").strip() or "기타"} +def soil_tiers_from_designs(designs: Any) -> dict[float, int]: + """B06 횡단 설계 → `{주인 측점: 선 유출 다단 수}` — 서버가 세운 `extra_walls` 목록이 있는 측점만 + (B06 `pipeExtraCount` 와 같은 수 · 54-11).""" + found: dict[float, int] = {} + for row in designs or []: + walls = (row.get("design") or {}).get("extra_walls") if isinstance(row, dict) else None + if isinstance(walls, list): + outlet = [w for w in walls if isinstance(w, dict) and w.get("side") == "outlet"] + found[round(float(row.get("chainage_m") or 0.0), 3)] = len(outlet) + return found + + def _as_options(values: dict[str, Any]) -> dict[str, Any]: """컨테이너 값 → 열이 읽는 칸 — 구조 = `structure` · `{쪽}_type` · 나머지 = 맨 칸 + `{쪽}_revet_*`.""" @@ -53,6 +66,7 @@ def _as_options(values: dict[str, Any]) -> dict[str, Any]: def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str, Any]]: """한 구조물의 선택 컨테이너 인스턴스마다 한 건(n 차례) — 컨테이너 없는 종류는 빈 목록.""" rows: list[dict[str, Any]] = [] + tiers = item.get("흙막이단") # None = 모름(저장된 것 그대로) for prefix, link, title in SPEC_CONTAINERS.get(item["type_id"], ()): head = re.compile(rf"^{prefix}_(\d+)_(.+)$") found: dict[int, dict[str, Any]] = {} @@ -61,6 +75,8 @@ def container_rows(item: dict[str, Any], links: dict[str, Any]) -> list[dict[str if hit: found.setdefault(int(hit.group(1)), {})[hit.group(2)] = value for n in sorted(found): + if prefix == "soil_add" and tiers is not None and n > tiers: + continue gid = f"{link}{n}" rows.append( { diff --git a/resources/tester/test_52_1b_pipe_links.py b/resources/tester/test_52_1b_pipe_links.py index 87ce7e553..8f6898236 100644 --- a/resources/tester/test_52_1b_pipe_links.py +++ b/resources/tester/test_52_1b_pipe_links.py @@ -82,3 +82,42 @@ def test_서버_컨테이너_표는_배관_명세와_짝(): 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"]) + + +def test_54_11_흙막이는_선_단_수까지만(): + """단 수 2 → 1 = 흙막이 줄 2 → 1(값은 옵션에 그대로) · 다시 2 = 되살아남 · 모름 = 저장된 것 전부.""" + from M02_MasterTemplete.M02_Table_Fill_Spec import soil_tiers_from_designs + + item = {"type_id": "pipe", "options": SOIL, "복사본": "pipe@40.0"} + + def soil(tiers): + rows = container_rows({**item, "흙막이단": tiers}, {}) + return [r["그룹"] for r in rows if r["그룹"].startswith("흙막이")] + + assert soil(2) == ["흙막이 1", "흙막이 2"] + assert soil(1) == ["흙막이 1"] and "soil_add_2_structure" in item["options"] + assert soil(0) == [] and soil(2) == ["흙막이 1", "흙막이 2"] and len(soil(None)) == 2 + assert len(container_rows({**item, "흙막이단": 0}, {})) == 1 # 도수로/돌붙임은 그대로 + walls = [{"side": "outlet"}, {"side": "basin"}, {"side": "outlet"}] + designs = [ + {"chainage_m": 40.0, "design": {"extra_walls": walls}}, + {"chainage_m": 60.0, "design": {"extra_walls": []}}, + {"chainage_m": 80.0, "design": {"pipe_length_m": 9}}, + ] + assert soil_tiers_from_designs(designs) == {40.0: 2, 60.0: 0} + + +def test_54_11_집계표_미지정_알림도_선_단_수만(world): + 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")) + + def note(tiers): + found = fill.fill_table(root, table, None, tiers)["알림"] + return next((n for n in found if n.startswith("집계 항목 미지정")), "") + + many, one = note(None), note({40.0: 1}) + assert many != one # 숨은 흙막이 2 가 미지정 알림에서 빠짐 + assert int(many.split()[3].rstrip("건")) - int(one.split()[3].rstrip("건")) == 1 diff --git a/resources/tester/ui_checks/54-11_흙막이_집계.js b/resources/tester/ui_checks/54-11_흙막이_집계.js new file mode 100644 index 000000000..e6f94bb91 --- /dev/null +++ b/resources/tester/ui_checks/54-11_흙막이_집계.js @@ -0,0 +1,138 @@ +/* 54-11 — 집계표 · 미지정 알림은 지금 보이는 흙막이 상자(단 수 안)만 셈 + * 시작 화면: B06(#/b06-section) · 검증 프로젝트(sub5_검증) · 목록에 다단이 서는 관(기본 「배관 2」 12+18) + * 부르는 법: orca eval --expression "window.__m5411={mode:'단계'}; $(cat 이 파일)" + * look → 단 수 · 보이는 흙막이 상자 · 파일의 흙막이 값 번호 · 집계표 줄 수 · 미지정 알림 + * prep → 보이는 흙막이 상자마다 구조물 조합 잇기 · 마지막 상자 형태 = form → [저장] → look + * tier+ / tier- → 유출 「추가 기슭막이(단)」 + / - → [저장] → look + * 기대: 단 수 2 → 1 에 상자 2 → 1 · 집계 줄(또는 미지정 건) 1 줄어듦 · 파일 값 그대로 · + * 다시 2 에 줄 · 상자 값 되살아남 + */ +(async () => { + const ARGS = window.__m5411 || { mode: "look" }; + const PIPE = ARGS.pipe || "배관 2"; + 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 fetchUrl = modUrl("/B04_PreProcess/B04_PreProcess_Api_Fetch.ts"); + const fetchApi = await import(fetchUrl); + const m02Api = await import( + fetchUrl.replace( + /B04_PreProcess\/B04_PreProcess_Api_Fetch\.ts.*$/, + "M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts", + ) + ); + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((n) => n.offsetParent !== null); + const soils = () => [...(spec()?.querySelectorAll('[data-spec-container="soil_add"]') ?? [])]; + const items = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const station = () => + items() + .find((l) => l.textContent.trim().endsWith(PIPE)) + ?.textContent.trim() + .split(PIPE)[0]; + const pickPipe = async () => { + await until(() => items().length > 0); + const chosen = document.querySelector(".b05-route__irregular-list li.is-selected"); + if (spec() && chosen?.textContent.trim().endsWith(PIPE)) return; + const li = items().find((l) => l.textContent.trim().endsWith(PIPE)); + (li.querySelector("button") ?? li).click(); + await until(() => spec(), 5000); + await w(800); + }; + const tierRow = () => + spec() + ?.querySelector('[data-spec-container="outlet"]') + ?.querySelector(".b06-structure-panel__struct--tier:not(.is-hidden)"); + const look = async () => { + const filled = (await m02Api.readFilled(projectId, "구조물집계표")).문서; + const points = (await fetchApi.fetchDetailPipePoints(projectId)).pipe_points ?? []; + const sta = station(); + // 흙막이 값이 있는 관 지점마다 파일의 흙막이 번호 + const fileSoil = Object.fromEntries( + points + .map((p) => [ + p.chainage_m, + [ + ...new Set( + Object.keys(p.options ?? {}) + .map((k) => /^soil_add_(\d+)_/.exec(k)?.[1]) + .filter(Boolean), + ), + ].sort(), + ]) + .filter(([, list]) => list.length), + ); + return { + station: sta, + tier: tierRow()?.querySelector(".b06-structure-panel__hval")?.textContent ?? null, + soilBoxes: soils().length, + soilValues: soils().map( + (b) => + b.querySelector( + '[data-spec-item="soil_add.form"] select, [data-spec-item="soil_add.form"] input', + )?.value ?? "", + ), + fileSoil, + rows: filled.줄.filter((r) => !r.고정).length, + notes: (filled.알림 ?? []).filter((n) => n.startsWith("집계 항목")), + }; + }; + const pickLink = async (box) => { + 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 save = async () => { + document.querySelector('[data-dock-action="save"]').click(); + await w(500); + out.saved = await until(() => !document.querySelector(".ui-overlay.is-active"), 90000); + await w(1500); + }; + await pickPipe(); + const out = { mode: ARGS.mode, pipe: PIPE }; + // prep — 보이는 흙막이 상자마다 구조물 조합 잇기 · 마지막 상자 형태 = ARGS.form → [저장] + if (ARGS.mode === "prep") { + for (const box of soils()) await pickLink(box); + const el = soils().at(-1)?.querySelector('[data-spec-item="soil_add.form"] select'); + if (el && ARGS.form) { + el.value = ARGS.form; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(1500); + } + await save(); + } + if (ARGS.mode === "tier+" || ARGS.mode === "tier-") { + const btns = [...(tierRow()?.querySelectorAll("button") ?? [])]; + btns.find((b) => b.textContent === (ARGS.mode === "tier+" ? "+" : "-"))?.click(); + await w(1500); + await save(); + } + Object.assign(out, await look()); + return JSON.stringify(out); +})(); From fe2aa55bdbcc256cfd924b31cd7576063c76c34b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:36:21 +0900 Subject: [PATCH 04/99] =?UTF-8?q?chore(plan):=2010-6=20=EC=95=8C=EC=95=BD?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EB=9D=BC=EB=B2=A8=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=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 740f9584f..a5689c2db 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -160,3 +160,6 @@ (10-6 사용자 · 추가 요청) - 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성 + +(10-6 사용자 · 다음 수정) + - B05/B06의 종단도 내널에서 구조물 알약을 선택하면 정보 라벨이 나오는데 위치가 상하 제어 버튼에 가려져서 안보여 최상위로 올려주고 그럼 버튼이 가려지니까 Y축 방향으로 조금 쉬프트 해줘. 그리고 안에 내용은 중요한 정보만 보여주면 됨. 지금은 너무 많이 나와. 중요한 정보라면 아마 기준 측점 정보와 컨테이너가 있는 경우 제일 앞에 있는 옵션값만 있어도 될것같아. From dfe13823771d8f5d36f6830c04c899f8577f92d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:40:39 +0900 Subject: [PATCH 05/99] =?UTF-8?q?fix(structure):=20=ED=95=84=EC=88=98=20?= =?UTF-8?q?=EC=B9=B8=20=EB=B9=88=20=EC=98=9B=20=EA=B5=AC=EC=A1=B0=EB=AC=BC?= =?UTF-8?q?=EB=8F=84=20=EB=8B=A4=EB=A5=B8=20=EC=B9=B8=20=C2=B7=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EB=B0=94=EA=BF=88=20=EB=B0=94=EB=A1=9C=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20=C2=B7=20=EB=B9=88=20=EC=B9=B8=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=20+=20=EC=95=88=EB=82=B4=20=ED=95=9C=20=EC=A4=84(54-12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 실시간 반영이 빈 필수 칸에서 멈추고 포커스만 보내던 것 풂 · [추가] · 서버 [저장] 막기 그대로 - 빈 필수 칸 붉은 테두리 + 그 칸 상자 안 「필수 칸 비어 있음 — [저장] 전 채움」(B05 = B06) - 시험 test_54_12_live_required · 옛 「실시간 멈춤」 기대 한 줄 바꿈 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B05_Profile_UI_Structures_Panel.ts | 5 +- .../B05_Profile_UI_Structures_Panel_Commit.ts | 3 +- ...B05_Profile_UI_Structures_Panel_Options.ts | 25 ++++ .../B05_Profile_UI_Style_Structures.css | 12 ++ .../tester/helper_54_12_live_required.cjs | 111 ++++++++++++++++++ resources/tester/test_54_12_live_required.py | 58 +++++++++ .../test_b05_structures_form_summary.py | 4 +- 7 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 resources/tester/helper_54_12_live_required.cjs create mode 100644 resources/tester/test_54_12_live_required.py diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index 150f47e9f..4f3e2e137 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -25,6 +25,7 @@ import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field, optionDefaults } from "./B05_Profile_UI_Structures_Fields"; import { hidesTypeSummary, + markRequired, paintStale, renderOptionFields as renderOptionFieldsInto, } from "./B05_Profile_UI_Structures_Panel_Options"; @@ -99,12 +100,14 @@ export function createStructuresSection( groupBoxes, } = form; /** 옵션 그룹 상자를 지금 종류 · 칸에 맞춰 다시 세움(PLAN 46-3) — 칸을 그린 뒤마다 부름. */ - const regroup = (): void => + const regroup = (): void => { groupBoxes.render( currentType(), [optionRow, facilityOptions.root], facilityFormKind(currentType()), ); + markRequired(optionInputs, optionRow); // 빈 필수 칸 안내 = 칸이 옮겨 간 상자 안(54-12) + }; /** 이 타입이 서브폼(계곡 통과 시설 부속)을 쓰는지 — 쓰면 레지스트리 옵션 칸 대신 * 서브폼이 그린다. 독립 기슭막이(D4)는 서브폼을 떠나 C군과 같은 레지스트리 옵션 diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts index 06ab97d63..222588462 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Commit.ts @@ -329,8 +329,9 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis } // 필수 옵션(미확정 기본값 없음)이 비어 있으면 추가하지 않는다 — 서버도 거절한다. // 상세(detail) 옵션은 폼에 없으므로 여기 걸리지 않는다(B06/B07에서 받는다). + // 실시간 반영은 막지 않음 — 빈 칸은 표시 · 안내만(`markRequired` · 54-12), 막기는 [추가] · 서버 [저장] const missing = ctx.optionInputs.find((entry) => entry.required && entry.isEmpty()); - if (missing) { + if (missing && !live) { missing.input.focus(); return; } diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts index 703d97378..bed251a8b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts @@ -112,8 +112,10 @@ export function renderOptionFields( // 상위 값이 바뀌면 하위 기본값 · 선택지 · 보이기를 먼저 맞추고 반영(48-4) input.addEventListener("change", () => { applyRules(option.key); + markRequired(optionInputs, optionRow); ctx.liveCommit(); }); + input.addEventListener("input", () => markRequired(optionInputs, optionRow)); all.push({ wrap, entry: { @@ -203,6 +205,29 @@ export function renderOptionFields( ctx.syncRangeDisplay(); } +/** 빈 필수 칸 표시(54-12) — 칸 테두리 + 그 칸이 든 상자(격자) 안 안내 한 줄. 칸이 그룹 상자로 옮겨 간 + * 뒤에도 맞게 패널이 칸 그리기 · 그룹 다시 세우기 · 값 바꿈마다 부름. 막지 않음(막기는 [추가] · [저장]). */ +export function markRequired( + optionInputs: OptionRenderContext["optionInputs"], + optionRow: HTMLElement, +): void { + const scope = optionRow.parentElement ?? optionRow; + scope.querySelectorAll(".b05-structure__required-note").forEach((note) => note.remove()); + scope + .querySelectorAll(".is-required-empty") + .forEach((input) => input.classList.remove("is-required-empty")); + for (const entry of optionInputs) { + if (!entry.required || !entry.isEmpty()) continue; + entry.input.classList.add("is-required-empty"); + const box = entry.input.closest("[data-option-keys]")?.parentElement; + if (!box || box.querySelector(":scope > .b05-structure__required-note")) continue; + const note = document.createElement("p"); + note.className = "b05-structure__required-note"; + note.textContent = "필수 칸 비어 있음 — [저장] 전 채움"; + box.append(note); + } +} + /** 고르기 칸 보기를 규칙 선택지로 — 빈 보기는 남기고, 지금 값이 목록 밖이면 그 값만 남겨 보임. */ function syncChoices( element: HTMLSelectElement, diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 65ba8eb49..cde7f1c91 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -244,6 +244,18 @@ background: rgba(224, 91, 91, 0.08); } +/* 빈 필수 칸 — 칸 테두리 + 그 칸이 든 상자 안 안내 한 줄(54-12) */ +.b05-route__field .is-required-empty { + border-color: #e05b5b; +} + +.b05-structure__required-note { + grid-column: 1 / -1; + margin: 0; + color: #e05b5b; + font-size: var(--text-caption, 12px); +} + /* 측점 범위(계산 표시) — C군 등 길이 옵션 기반 구간 타입. 입력 칸이 아니라 기준 * 측점 + 길이로 계산된 "시작 ~ 종료"를 보여만 준다(2026-08-19 사용자 지시). * 글자 크기는 기준 측점 입력 칸과 동일(크롬 input 기본 13.333px — 패널 입력에 diff --git a/resources/tester/helper_54_12_live_required.cjs b/resources/tester/helper_54_12_live_required.cjs new file mode 100644 index 000000000..4caaa0424 --- /dev/null +++ b/resources/tester/helper_54_12_live_required.cjs @@ -0,0 +1,111 @@ +/* 54-12 빈 필수 칸 실시간 반영 헬퍼 — `_Structures_Panel_Commit.commit` 을 Node 로 돌려 + * 결과를 표준출력 JSON 한 벌로. 화면 그림은 안 씀(ORCA 가 따로 봄). + * TS 로더는 `helper_54_9_etc_pill.cjs` 와 같다. + */ +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(); +const session = new Map(); +const fakeStorage = { + getItem: (k) => session.get(k) ?? null, + setItem: (k, v) => session.set(k, String(v)), + removeItem: (k) => session.delete(k), +}; +globalThis.window = { sessionStorage: fakeStorage, localStorage: fakeStorage }; +globalThis.sessionStorage = fakeStorage; +globalThis.localStorage = fakeStorage; +const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api", API_TIMEOUT_MS: 1000 } }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + if (request.startsWith("@")) return new Proxy({}, { get: () => () => {} }); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { commit } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel_Commit.ts"), +); + +const type = { + type_id: "stone_wall_test", + name: "돌쌓기(찰)", + group: "C", + placement: "point", + options: [ + { key: "height_m", label: "높이", input: "number" }, + { key: "back_length_m", label: "뒷길이", input: "number", required: true, phase: "detail" }, + ], +}; + +function run(live, height) { + const focused = []; + const entry = (key, value, required) => { + const input = { value, focus: () => focused.push(key), classList: { add() {} } }; + return { + key, + required, + input, + read: () => Number(input.value) || 0, + isEmpty: () => input.value.trim() === "", + }; + }; + const structures = [ + { + structure_id: "s1", + type_id: type.type_id, + placement: "point", + chainage_m: 80, + options: { height_m: 1.5 }, + }, + ]; + const changes = []; + const anchor = { read: () => 100, station: { focus: () => focused.push("anchor") } }; + const ctx = { + callbacks: { onChange: (next) => changes.push(next) }, + structures, + memoField: { value: "" }, + anchorFields: anchor, + startFields: anchor, + endFields: anchor, + optionRow: null, + optionInputs: [entry("height_m", String(height), false), entry("back_length_m", "", true)], + pipeFacilities: () => [], + interval: () => 20, + typeMap: () => new Map([[type.type_id, type]]), + currentType: () => type, + facilityFormKind: () => null, + hasComputedRange: () => false, + summary: () => null, + editingId: () => "s1", + groupBoxes: { get: () => ({}), shapes: () => ({}), shapeKeys: () => [] }, + renderList: () => {}, + cancelTempPipe: () => {}, + }; + return commit(ctx, live).then(() => ({ + changes: changes.length, + height: structures[0].options.height_m, + chainage: structures[0].chainage_m, + hasBack: "back_length_m" in structures[0].options, + focused, + })); +} + +Promise.all([run(true, 3.2), run(false, 3.2)]).then(([live, button]) => { + process.stdout.write(JSON.stringify({ live, button })); +}); diff --git a/resources/tester/test_54_12_live_required.py b/resources/tester/test_54_12_live_required.py new file mode 100644 index 000000000..fb41486a3 --- /dev/null +++ b/resources/tester/test_54_12_live_required.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +"""54-12 필수 칸(뒷길이 등)이 빈 옛 구조물 — 다른 칸 · 기준 측점 바꿈은 실시간 반영 · [추가]는 그대로 막음. + +Node 헬퍼가 `_Structures_Panel_Commit.commit` 을 돌림(`helper_54_12_live_required.cjs`). +빈 칸 표시 · 안내 한 줄은 ORCA 로 따로 봄. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_12_live_required.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_실시간_반영은_빈_필수_칸이_있어도_나간다(out): + live = out["live"] + assert live["changes"] == 1 + assert live["height"] == 3.2 + assert live["chainage"] == 100 + assert live["hasBack"] is False # 빈 칸은 0 으로 채우지 않음 + assert live["focused"] == [] # 포커스를 빈 칸으로 뺏지 않음 + + +def test_추가_수정_버튼은_종전대로_막는다(out): + button = out["button"] + assert button["changes"] == 0 + assert button["height"] == 1.5 + assert button["focused"] == ["back_length_m"] + + +def test_빈_필수_칸_표시는_패널_세_곳에서_부른다(): + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel_Options.ts"), + encoding="utf-8", + ) as handle: + options = handle.read() + assert "필수 칸 비어 있음 — [저장] 전 채움" in options + assert options.count("markRequired(optionInputs, optionRow)") == 2 # change · input + with open( + os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Panel.ts"), encoding="utf-8" + ) as handle: + assert "markRequired(optionInputs, optionRow)" in handle.read() # 그룹 상자 다시 세운 뒤 diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index eca79410f..b75785843 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -116,8 +116,8 @@ def test_저장_기록에_집계_항목이_실리고_해제하면_칸이_빠짐( def test_집계_항목만_바뀌면_폼_검증과_무관하게_기록에_반영(out): - # 흠 2 — 필수 옵션이 비면 종전 commit 은 조용히 멈춤 - assert out["formBlocks"] is True + # 흠 2 — 필수 옵션이 비어도 실시간 반영은 나감(54-12 · 막기는 [추가] · [저장]) + assert out["formBlocks"] is False # 집계 항목 전용 반영은 그 검증을 안 거침 · 옵션은 그대로 assert out["summaryOnlyAssign"] == { "done": True, From fb426fb17e2e07e29ea37fd9b35893518688f7b6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:43:32 +0900 Subject: [PATCH 06/99] =?UTF-8?q?chore(plan):=2010-6=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20=EC=9E=87=EA=B8=B0=20=EB=9E=A8?= =?UTF-8?q?=ED=94=84=20=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 a5689c2db..241734bd4 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -163,3 +163,6 @@ (10-6 사용자 · 다음 수정) - B05/B06의 종단도 내널에서 구조물 알약을 선택하면 정보 라벨이 나오는데 위치가 상하 제어 버튼에 가려져서 안보여 최상위로 올려주고 그럼 버튼이 가려지니까 Y축 방향으로 조금 쉬프트 해줘. 그리고 안에 내용은 중요한 정보만 보여주면 됨. 지금은 너무 많이 나와. 중요한 정보라면 아마 기준 측점 정보와 컨테이너가 있는 경우 제일 앞에 있는 옵션값만 있어도 될것같아. + +(10-6 사용자 · 신규 기능) +다음은 신규 기능이야. 구조물 조합을 지정해야하는 경우에가 있는데 이때 좌측 패널의 구조물 리스트의 항목들에 측점정보 좌측에 연결되었거나 연결되지 않은 정보를 알려주는 기능이 있었으면 함. 이때 구조물에 따라 선택이나 필수 정보들에 의해 램프와 램프의 색상으로 표현되게 하면 될것같아. 램프영역에 의해 글자들의 배열이 깨지는건 싫으니 별도의 영역을 먼저지정하고 램프는 3행 다열 구조로 구성해줘. 램프의 사이즈는 작아도 되니 현재의 리스트 높이를 깨지 않는 범위에서 진행해줘. From e8698a7fe3f220507653f95cdb2a80211df41145 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:44:34 +0900 Subject: [PATCH 07/99] =?UTF-8?q?chore(docs):=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20=EB=AC=B8=EC=84=9C=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/raw/사용자요구/2026-10-05_수정요구.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 740f9584f..241734bd4 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -160,3 +160,9 @@ (10-6 사용자 · 추가 요청) - 안전부대용지 그룹의 사토장의 기준측점 표현이 예전 코드입. 토취장과 동일하게 구성 + +(10-6 사용자 · 다음 수정) + - B05/B06의 종단도 내널에서 구조물 알약을 선택하면 정보 라벨이 나오는데 위치가 상하 제어 버튼에 가려져서 안보여 최상위로 올려주고 그럼 버튼이 가려지니까 Y축 방향으로 조금 쉬프트 해줘. 그리고 안에 내용은 중요한 정보만 보여주면 됨. 지금은 너무 많이 나와. 중요한 정보라면 아마 기준 측점 정보와 컨테이너가 있는 경우 제일 앞에 있는 옵션값만 있어도 될것같아. + +(10-6 사용자 · 신규 기능) +다음은 신규 기능이야. 구조물 조합을 지정해야하는 경우에가 있는데 이때 좌측 패널의 구조물 리스트의 항목들에 측점정보 좌측에 연결되었거나 연결되지 않은 정보를 알려주는 기능이 있었으면 함. 이때 구조물에 따라 선택이나 필수 정보들에 의해 램프와 램프의 색상으로 표현되게 하면 될것같아. 램프영역에 의해 글자들의 배열이 깨지는건 싫으니 별도의 영역을 먼저지정하고 램프는 3행 다열 구조로 구성해줘. 램프의 사이즈는 작아도 되니 현재의 리스트 높이를 깨지 않는 범위에서 진행해줘. From 2104cc3597c13bf126872f95908f084499be60d9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:46:45 +0900 Subject: [PATCH 08/99] =?UTF-8?q?chore(plan):=2010-6=20B05=20=C2=B7=20B06?= =?UTF-8?q?=20=EC=9E=90=EB=A3=8C=20=ED=95=9C=20=EA=B3=B3=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=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 241734bd4..4cb7b0dac 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -166,3 +166,6 @@ (10-6 사용자 · 신규 기능) 다음은 신규 기능이야. 구조물 조합을 지정해야하는 경우에가 있는데 이때 좌측 패널의 구조물 리스트의 항목들에 측점정보 좌측에 연결되었거나 연결되지 않은 정보를 알려주는 기능이 있었으면 함. 이때 구조물에 따라 선택이나 필수 정보들에 의해 램프와 램프의 색상으로 표현되게 하면 될것같아. 램프영역에 의해 글자들의 배열이 깨지는건 싫으니 별도의 영역을 먼저지정하고 램프는 3행 다열 구조로 구성해줘. 램프의 사이즈는 작아도 되니 현재의 리스트 높이를 깨지 않는 범위에서 진행해줘. + +(10-6 사용자 · 중요) +이건 중요한 부분인데 B05와 B06은 같은 프론트엔드를 공유하고 같은 결과와 묶여있어야하는데 지금 분리되어있는것같아. 하나를 변경하면 다른 페이지로 이동되었을때 이전자료를 가지고 있는것같아. 저장하는 데이터 셋의 위치가 일원화 되어있지 않는것같은데. 확인해봐. From fa4e517b4cfa5aaac16eed962e3f69d7acb90ad4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:50:06 +0900 Subject: [PATCH 09/99] =?UTF-8?q?chore(plan):=2010-6=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EC=A4=84=202=ED=96=89=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 4cb7b0dac..8129002e6 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -169,3 +169,6 @@ (10-6 사용자 · 중요) 이건 중요한 부분인데 B05와 B06은 같은 프론트엔드를 공유하고 같은 결과와 묶여있어야하는데 지금 분리되어있는것같아. 하나를 변경하면 다른 페이지로 이동되었을때 이전자료를 가지고 있는것같아. 저장하는 데이터 셋의 위치가 일원화 되어있지 않는것같은데. 확인해봐. + +(10-6 사용자 · 추가 요청) + - 구조물 배치에 구조물 조합 선택의 프론트엔드를 2행 구조로 변경해줘. 다른 옵션들을 보면 제목과 입력을 2행 구조로 반영되어 있잖아. 글자 폰트와 크기도 같은걸 사용해줘. From 968e11c46d7b68ebd8aeebdf00712100b7a45dbb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:52:25 +0900 Subject: [PATCH 10/99] =?UTF-8?q?chore(plan):=2010-6=203D=20=EC=9B=90?= =?UTF-8?q?=EA=B7=BC=20=C2=B7=20=EC=A7=81=EA=B5=90=20=EB=8B=A8=EC=B6=94=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 8129002e6..6d86979b0 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -172,3 +172,6 @@ (10-6 사용자 · 추가 요청) - 구조물 배치에 구조물 조합 선택의 프론트엔드를 2행 구조로 변경해줘. 다른 옵션들을 보면 제목과 입력을 2행 구조로 반영되어 있잖아. 글자 폰트와 크기도 같은걸 사용해줘. + +(10-6 사용자 · 추가항목) +B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 제목은 원근, 직교 로 변경하고 기본값을 직교로 변경해줘. From b5ded105b87f0d070e89d13013972ebd826543d0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:53:45 +0900 Subject: [PATCH 11/99] =?UTF-8?q?chore(plan):=2010-6=203D=20FRONT=20=C2=B7?= =?UTF-8?q?=20SIDE=20=EB=B0=A9=ED=96=A5=20=EC=9A=94=EC=B2=AD=20=EC=9B=90?= =?UTF-8?q?=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 6d86979b0..3fb63dd6c 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -175,3 +175,6 @@ (10-6 사용자 · 추가항목) B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 제목은 원근, 직교 로 변경하고 기본값을 직교로 변경해줘. + +(10-6 사용자 · 추가항목) + - B05의 3D 영역에서 Front, Side버튼의 방향이 대각선 방향인것같아. 방향 재설정 해줘. From b2e5f5adf3a8d8bcfe89249c943b1aed54406ba8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:56:47 +0900 Subject: [PATCH 12/99] =?UTF-8?q?feat(structure):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=A1=B0=ED=95=A9=20=EC=9E=87=EA=B8=B0=20=EC=A4=84?= =?UTF-8?q?=EC=9D=84=20=EC=98=B5=EC=85=98=20=EC=B9=B8=20=EA=BC=B4=202?= =?UTF-8?q?=ED=96=89=EC=9C=BC=EB=A1=9C(54-17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위 제목 「구조물 조합」(옵션 칸 제목과 같은 꼴) · 아래 이름 칸(읽기 전용 입력 칸) + [선택] [해제] - 공용 한 곳(Summary_Row) — 명세 컨테이너 · 정의 그룹 상자 · 종류 단위 줄 모두 · 동작 · 저장 값 그대로 - 옛 한 줄 CSS 걷어 냄 · 옵션 칸 공용 클래스 다시 씀 - 시험 · ui_check 의 이름 읽기를 value 로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- .../B05_Profile_UI_Structures_Summary_Row.ts | 18 +++-- .../B05_Profile_UI_Style_Structures.css | 23 +------ .../helper_b05_structures_form_summary.cjs | 11 +-- .../test_b05_structures_form_summary.py | 2 +- .../tester/ui_checks/52-1_배관_컨테이너.js | 2 +- .../ui_checks/52-2_박스암거_컨테이너.js | 2 +- .../tester/ui_checks/52-4_세월교_컨테이너.js | 2 +- .../tester/ui_checks/52-5_횡단수로_개거.js | 2 +- resources/tester/ui_checks/52-6_횡단맹암거.js | 4 +- .../tester/ui_checks/54-17_구조물조합_2행.js | 68 +++++++++++++++++++ .../ui_checks/54-6_사면안정_컨테이너.js | 2 +- 11 files changed, 98 insertions(+), 38 deletions(-) create mode 100644 resources/tester/ui_checks/54-17_구조물조합_2행.js diff --git a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts index 296417088..e0a1027ca 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Summary_Row.ts @@ -23,13 +23,18 @@ export interface SummaryRow { } export function buildSummaryRow(): SummaryRow { + // 옵션 칸과 같은 2행 꼴(54-17) — 위 제목(`b05-route__field > span`) · 아래 [이름 칸][선택][해제] const root = document.createElement("div"); - root.className = "b05-structure__summary"; + root.className = "b05-route__field b05-structure__summary"; root.dataset.b05Summary = "row"; const label = document.createElement("span"); label.className = "b05-structure__summary-label"; - label.textContent = "집계 항목"; - const name = document.createElement("span"); + label.textContent = "구조물 조합"; + // 이름 = 읽기 전용 입력 칸 — 옵션 입력 칸과 글꼴 · 크기 · 칸 모양이 같음 + const name = document.createElement("input"); + name.type = "text"; + name.readOnly = true; + name.tabIndex = -1; name.className = "b05-structure__summary-name"; const pick = document.createElement("button"); pick.type = "button"; @@ -41,12 +46,15 @@ export function buildSummaryRow(): SummaryRow { clear.className = "b05-route__irregular-btn"; clear.textContent = "해제"; clear.dataset.b05Summary = "clear"; - root.append(label, name, pick, clear); + const line = document.createElement("div"); + line.className = "b05-route__irregular-actions"; + line.append(name, pick, clear); + root.append(label, line); let current: SummaryItem | null = null; const set = (item: SummaryItem | null): void => { current = item; - name.textContent = summaryLabel(item); + name.value = summaryLabel(item); name.title = summaryTooltip(item); name.classList.toggle("is-assigned", item !== null); clear.disabled = item === null; diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index cde7f1c91..f1c2b5f1a 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -439,33 +439,16 @@ border-color: var(--color-warning, #c98a00); } -/* 집계 항목 줄(PLAN 21-4) — 옵션 · 시설 서브폼 아래 [이름][고르기][해제] 한 줄. 이름이 남는 폭을 - * 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색(`.is-assigned`) — 목록 줄과 같은 색. */ -.b05-structure__summary { - display: flex; - align-items: center; - gap: var(--spacing-8); - min-width: 0; -} - +/* 구조물 조합 잇기 줄(PLAN 21-4 · 54-17) — 옵션 칸 꼴(`b05-route__field`) 2행 · 아래 줄은 + * [이름 칸][선택][해제] · 이름 칸이 남는 폭을 먹고 말줄임 · 키는 툴팁에만. 지정되면 성공색. */ .b05-structure__summary[hidden] { display: none; } -.b05-structure__summary-label { - flex: none; - color: var(--color-text-secondary); - font-size: var(--text-caption); -} - .b05-structure__summary-name { flex: 1 1 auto; - min-width: 0; - overflow: hidden; - color: var(--color-text-secondary); - font-size: var(--text-caption); + align-self: center; text-overflow: ellipsis; - white-space: nowrap; } /* 글자는 성공색에 본문색을 섞어 대비 4.5 를 넘김(성공색 그대로는 밝은 테마에서 4.4). */ diff --git a/resources/tester/helper_b05_structures_form_summary.cjs b/resources/tester/helper_b05_structures_form_summary.cjs index 0a548359e..09c3e4f7c 100644 --- a/resources/tester/helper_b05_structures_form_summary.cjs +++ b/resources/tester/helper_b05_structures_form_summary.cjs @@ -199,9 +199,10 @@ out.pipeSet = merge.mergeFacilityOptions(pipePrev, { const row = buildSummaryRow(); const clicks = []; row.bind({ pick: () => clicks.push("pick"), clear: () => clicks.push("clear") }); -const [labelNode, nameNode, pickBtn, clearBtn] = row.root.children; +const [labelNode, line] = row.root.children; +const [nameNode, pickBtn, clearBtn] = line.children; out.rowEmpty = { - name: nameNode.textContent, + name: nameNode.value, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, get: row.get(), @@ -211,18 +212,18 @@ out.rowEmpty = { }; row.set(A); out.rowSet = { - name: nameNode.textContent, + name: nameNode.value, assigned: nameNode.classList.contains("is-assigned"), clearDisabled: clearBtn.disabled, title: nameNode.title, key: row.get().key, - nameShowsKey: nameNode.textContent.includes("SS000007"), + nameShowsKey: nameNode.value.includes("SS000007"), }; pickBtn.click(); clearBtn.click(); out.clicks = clicks; row.set(null); -out.rowCleared = { name: nameNode.textContent, clearDisabled: clearBtn.disabled }; +out.rowCleared = { name: nameNode.value, clearDisabled: clearBtn.disabled }; // 목록 줄 — 지정 색 · 툴팁 const list = new FakeNode("ul"); diff --git a/resources/tester/test_b05_structures_form_summary.py b/resources/tester/test_b05_structures_form_summary.py index b75785843..22ad774a1 100644 --- a/resources/tester/test_b05_structures_form_summary.py +++ b/resources/tester/test_b05_structures_form_summary.py @@ -80,7 +80,7 @@ def test_집계_항목_줄(out): True, None, ) - assert (empty["label"], empty["pick"], empty["clear"]) == ("집계 항목", "선택", "해제") + assert (empty["label"], empty["pick"], empty["clear"]) == ("구조물 조합", "선택", "해제") row = out["rowSet"] assert row["assigned"] is True and row["clearDisabled"] is False assert row["name"] == "돌기슭막이 메쌓기 1.2m" and A_KEY in row["title"] diff --git a/resources/tester/ui_checks/52-1_배관_컨테이너.js b/resources/tester/ui_checks/52-1_배관_컨테이너.js index 4153fc273..95257df30 100644 --- a/resources/tester/ui_checks/52-1_배관_컨테이너.js +++ b/resources/tester/ui_checks/52-1_배관_컨테이너.js @@ -69,7 +69,7 @@ c: b.dataset.specContainer, legend: b.querySelector("legend").firstChild.textContent, items: [...b.querySelectorAll("[data-spec-item]")].map((n) => n.dataset.specItem), - link: b.querySelector(".b05-structure__summary-name")?.textContent, + link: b.querySelector(".b05-structure__summary-name")?.value, })); const linkName = (c, i = 0) => box(c, i).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); diff --git a/resources/tester/ui_checks/52-2_박스암거_컨테이너.js b/resources/tester/ui_checks/52-2_박스암거_컨테이너.js index 67c606819..69f87a91e 100644 --- a/resources/tester/ui_checks/52-2_박스암거_컨테이너.js +++ b/resources/tester/ui_checks/52-2_박스암거_컨테이너.js @@ -65,7 +65,7 @@ values: [...b.querySelectorAll("[data-spec-item]")].map( (n) => n.querySelector("select, input").value, ), - link: b.querySelector(".b05-structure__summary-name")?.textContent, + link: b.querySelector(".b05-structure__summary-name")?.value, })); const linked = (c) => box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); diff --git a/resources/tester/ui_checks/52-4_세월교_컨테이너.js b/resources/tester/ui_checks/52-4_세월교_컨테이너.js index c5c9143e0..0be28be0b 100644 --- a/resources/tester/ui_checks/52-4_세월교_컨테이너.js +++ b/resources/tester/ui_checks/52-4_세월교_컨테이너.js @@ -63,7 +63,7 @@ n.dataset.specItem, n.querySelector("select, input").value, ]), - link: b.querySelector(".b05-structure__summary-name")?.textContent, + link: b.querySelector(".b05-structure__summary-name")?.value, })); const assigned = (c) => box(c).querySelector(".b05-structure__summary-name").classList.contains("is-assigned"); diff --git a/resources/tester/ui_checks/52-5_횡단수로_개거.js b/resources/tester/ui_checks/52-5_횡단수로_개거.js index 6745c6ed2..8b7b11211 100644 --- a/resources/tester/ui_checks/52-5_횡단수로_개거.js +++ b/resources/tester/ui_checks/52-5_횡단수로_개거.js @@ -52,7 +52,7 @@ summaryRightAfterPosition: after === summary, anchor: vis(anchorWrap), stale: vis(sec().querySelector(".b05-structure__stale")), - linked: summary.querySelector(".b05-structure__summary-name")?.textContent ?? "", + linked: summary.querySelector(".b05-structure__summary-name")?.value ?? "", }; }; const pickType = async (typeId) => { diff --git a/resources/tester/ui_checks/52-6_횡단맹암거.js b/resources/tester/ui_checks/52-6_횡단맹암거.js index 3519947f5..edd0922a7 100644 --- a/resources/tester/ui_checks/52-6_횡단맹암거.js +++ b/resources/tester/ui_checks/52-6_횡단맹암거.js @@ -1,4 +1,4 @@ -/* 52-6 — 횡단배수 › 맹암거(표 06 · 새 종류 `cross_underdrain`) · B05 · B06 「구조물 배치」 +/* 52-6 — 횡단배수 › 맹암거(표 06 · 새 종류 `cross_underdrain`) · B05 · B06 「구조물 배치」 * 시작 화면: B05 종단설계(#/b05-profile) · 아무것도 안 고름 · 측점 3+7 이 비어 있음 * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m526 = "단계";` 를 붙여 * orca eval --expression "window.__m526='edit'; $(cat 이 파일)" → 종류 목록 · 컨테이너 · 값 · 잇기 · [추가] · 다시 열기 @@ -53,7 +53,7 @@ (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, + link: b?.querySelector(".b05-structure__summary-name").value, linked: b?.querySelector(".b05-structure__summary-name").classList.contains("is-assigned"), linkRows: [...sec().querySelectorAll("[data-b05-summary=row]")].filter(vis).length, }; diff --git a/resources/tester/ui_checks/54-17_구조물조합_2행.js b/resources/tester/ui_checks/54-17_구조물조합_2행.js new file mode 100644 index 000000000..04aab2119 --- /dev/null +++ b/resources/tester/ui_checks/54-17_구조물조합_2행.js @@ -0,0 +1,68 @@ +/* 54-17 — B05 · B06 좌측 「구조물 배치」 구조물 조합 잇기 줄 = 옵션 칸 꼴 2행 + * 시작 화면: B05 종단설계(#/b05-profile) 또는 B06 횡단설계(#/b06-section) · 검증 프로젝트(랩탑_보조) + * 부르는 법: 한 번 — 배관 · 맹암거 · 반사경 · 옹벽을 폼에서 골라(저장 안 함) 보이는 잇기 줄을 잼 · + * 결과를 sessionStorage `m5417` 에 page 별로 두고 B05 = B06 을 견줌 · 끝나면 폼 고르기를 비움 + * 판정: 제목 · 값 두 줄(제목 아래 = 값) · 제목 글꼴/크기/색 = 옵션 칸 제목 · 값 글꼴/크기/높이/테두리 = 입력 칸 + * 쓰는 것: 없음(폼 고르기만 · 저장 안 함) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const page = location.hash.includes("b06") ? "B06" : "B05"; + const sec = document.querySelector(".b05-structure-section"); + const [g, t] = sec.querySelectorAll(".b05-structure__grid select"); + const pick = async (el, v) => { + el.value = v; + el.dispatchEvent(new Event("change", { bubbles: true })); + await w(400); + }; + const vis = (el) => !!el && el.getClientRects().length > 0; + const cs = (el) => getComputedStyle(el); + const font = (el) => [cs(el).fontFamily, cs(el).fontSize, cs(el).color].join("|"); + const box = (el) => + [ + cs(el).fontFamily, + cs(el).fontSize, + cs(el).height, + cs(el).padding, + cs(el).borderTopWidth, + cs(el).borderRadius, + ].join("|"); + const TARGETS = { + 배관: ["A", "pipe"], + 맹암거: ["A", "cross_underdrain"], + 반사경: ["E", "mirror"], + 옹벽: ["C", "retaining_wall"], + }; + const out = {}; + for (const [name, [gv, tv]] of Object.entries(TARGETS)) { + await pick(g, gv); + await pick(t, tv); + const rows = [...sec.querySelectorAll("[data-b05-summary=row]")].filter(vis); + const cap = [...sec.querySelectorAll(".b05-route__field > span")].find( + (s) => vis(s) && !s.closest("[data-b05-summary]"), + ); + const input = [ + ...sec.querySelectorAll(".b05-route__field input, .b05-route__field select"), + ].find((i) => vis(i) && !i.closest("[data-b05-summary]") && i.type !== "checkbox"); + out[name] = rows.map((r) => { + const title = r.children[0]; + const nameEl = r.querySelector(".b05-structure__summary-name"); + return { + title: title.textContent, + value: nameEl.value, + twoLines: title.getBoundingClientRect().bottom <= nameEl.getBoundingClientRect().top, + titleFont: font(title) === font(cap), + valueBox: box(nameEl) === box(input), + buttons: [...r.querySelectorAll("button")].map((b) => b.textContent).join(","), + fonts: { title: font(title), value: box(nameEl) }, + }; + }); + } + await pick(g, ""); + const store = JSON.parse(sessionStorage.getItem("m5417") || "{}"); + store[page] = out; + sessionStorage.setItem("m5417", JSON.stringify(store)); + const strip = (o) => JSON.stringify(o, (k, v) => (k === "value" ? undefined : v)); + const same = store.B05 && store.B06 ? strip(store.B05) === strip(store.B06) : null; + return JSON.stringify({ page, out, B05eqB06: same }); +})(); diff --git a/resources/tester/ui_checks/54-6_사면안정_컨테이너.js b/resources/tester/ui_checks/54-6_사면안정_컨테이너.js index 08d22c00c..080b98047 100644 --- a/resources/tester/ui_checks/54-6_사면안정_컨테이너.js +++ b/resources/tester/ui_checks/54-6_사면안정_컨테이너.js @@ -79,7 +79,7 @@ typeRows: [...section().querySelectorAll(".b05-structure__summary")].filter( (n) => !n.closest("[data-b05-group]") && visible(n), ).length, - link: box?.querySelector(".b05-structure__summary-name")?.textContent.trim(), + link: box?.querySelector(".b05-structure__summary-name")?.value.trim(), }; }; const ok = (type, s) => From d05a0de164b13507f31732cff34fe6611497f56d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:58:37 +0900 Subject: [PATCH 13/99] =?UTF-8?q?feat(B05):=203D=20=EC=9B=90=EA=B7=BC=20?= =?UTF-8?q?=C2=B7=20=EC=A7=81=EA=B5=90=20=EB=91=90=20=EB=8B=A8=EC=B6=94=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B0=EB=B3=B8=20=EC=A7=81=EA=B5=90=20=C2=B7=20FRO?= =?UTF-8?q?NT=20=C2=B7=20SIDE=20=EC=88=98=ED=8F=89=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 「원근으로/직교로」 토글을 「원근」 「직교」 두 단추로 · 켜진 쪽 강조 - 카메라 기본 = 직교 - FRONT = 남에서 북 수평 · SIDE = 동에서 서 수평 · 앞 가장자리 반폭만큼 물려 화면맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- B05_Profile/B05_Profile_UI_Panel_View.ts | 36 +++++++++++++-------- B05_Profile/B05_Profile_UI_Viewer.ts | 9 ++++-- B05_Profile/B05_Profile_UI_Viewer_Camera.ts | 5 ++- 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Panel_View.ts b/B05_Profile/B05_Profile_UI_Panel_View.ts index 6b340ab0e..b09d2e0e0 100644 --- a/B05_Profile/B05_Profile_UI_Panel_View.ts +++ b/B05_Profile/B05_Profile_UI_Panel_View.ts @@ -33,19 +33,29 @@ export function createViewControls(callbacks: ViewControlCallbacks): { (["iso", "top", "front", "side"] as const).forEach((preset) => viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset), "glass")), ); - // 직교/원근 전환(2026-09-04 사용자 지시) — 기본은 원근이고, 단추 글자는 **바뀔 쪽**을 - // 가리킨다(누르면 그쪽으로 간다). - let projection: "perspective" | "ortho" = "perspective"; - const projectionButton = button( - "직교로", - () => { - projection = projection === "perspective" ? "ortho" : "perspective"; - projectionButton.textContent = projection === "perspective" ? "직교로" : "원근으로"; - callbacks.onProjection(projection); - }, - "glass", - ); - viewButtons.append(projectionButton); + // 원근·직교 두 단추 — 지금 켜진 쪽을 강조한다. 기본은 직교(카메라 기본과 같음). + const projectionButtons = ( + [ + ["perspective", "원근"], + ["ortho", "직교"], + ] as const + ).map(([kind, label]) => { + const element = button( + label, + () => { + projectionButtons.forEach(({ kind: other, element: el }) => { + el.classList.toggle("is-active", other === kind); + el.setAttribute("aria-pressed", String(other === kind)); + }); + callbacks.onProjection(kind); + }, + "glass", + ); + element.classList.toggle("is-active", kind === "ortho"); + element.setAttribute("aria-pressed", String(kind === "ortho")); + return { kind, element }; + }); + viewButtons.append(...projectionButtons.map(({ element }) => element)); const visibilityButtons = document.createElement("div"); visibilityButtons.className = "b05-route__view-group"; visibilityButtons.append( diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 165456dad..1d0d2e415 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -289,8 +289,9 @@ export function createRouteViewer(): RouteViewer { iso: [distance, distance, distance], // 정확히 수직이면 lookAt이 화면 방향을 못 정해 첫 드래그에 화면이 뒤집힌다. top: [0, distance, distance * TOP_VIEW_TILT], - front: [0, distance * 0.25, distance], - side: [distance, distance * 0.25, 0], + // 정면 = 남에서 북을, 측면 = 동에서 서를 수평으로 본다(장면 +z 가 남, +x 가 동). + front: [0, 0, distance], + side: [distance, 0, 0], } as const; const [x, y, z] = positions[view]; const camera = cameraRig.camera(); @@ -309,7 +310,9 @@ export function createRouteViewer(): RouteViewer { if (!bounds) return; const width = bounds.x[1] - bounds.x[0]; const depth = bounds.y[1] - bounds.y[0]; - placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35, view); + // 수평 보기는 지형 앞 가장자리가 카메라에 가까워 원근에서 잘린다 — 그 반폭만큼 물린다. + const nearHalf = view === "front" ? depth / 2 : view === "side" ? width / 2 : 0; + placeCamera(new THREE.Vector3(0, 0, 0), Math.max(width, depth, 20) * 1.35 + nearHalf, view); } async function reloadContours(interval: number): Promise { diff --git a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts index 32f94f3fd..7dbe1cdd7 100644 --- a/B05_Profile/B05_Profile_UI_Viewer_Camera.ts +++ b/B05_Profile/B05_Profile_UI_Viewer_Camera.ts @@ -2,8 +2,7 @@ * B05_Profile_UI_Viewer_Camera.ts * B05 뷰어의 **원근/직교 두 카메라**와 그 사이 갈아 끼우기. * - * 기본은 원근(시야각 45°) — B04 지표면 화면과 같은 조작감이다(2026-09-04 사용자 확정). - * 탑뷰에서 크기를 정밀하게 대조할 때만 직교로 바꾼다. 갈아 끼울 때 위치·시선·근평면· + * 기본은 직교 — 원근(시야각 45°)은 단추로 고른다. 갈아 끼울 때 위치·시선·근평면· * 먼평면과 **보이는 크기**를 그대로 옮기므로 화면이 튀지 않는다. * * 카메라 객체가 바뀌므로 쓰는 쪽은 붙잡아 두지 말고 `camera()`로 그때그때 읽을 것. @@ -22,7 +21,7 @@ export function createCameraRig() { const perspective = new THREE.PerspectiveCamera(FOV, 1, 0.1, 100000); perspective.position.set(100, 120, 100); const ortho = new THREE.OrthographicCamera(-100, 100, 100, -100, 0.1, 100000); - let kind: ProjectionKind = "perspective"; + let kind: ProjectionKind = "ortho"; let aspect = 1; let halfHeight = 100; From ae43a25b1a4f666566c94da3b119eeaac7e23ce8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 11:59:43 +0900 Subject: [PATCH 14/99] =?UTF-8?q?chore(plan):=2010-6=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20=ED=8C=A8=EB=84=90=20=EA=B3=B5=ED=86=B5=20=EB=8B=A8=EC=B6=94?= =?UTF-8?q?=20=ED=81=AC=EA=B8=B0=20=EC=A0=90=EA=B2=80=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=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 3fb63dd6c..32518256a 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -178,3 +178,6 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 (10-6 사용자 · 추가항목) - B05의 3D 영역에서 Front, Side버튼의 방향이 대각선 방향인것같아. 방향 재설정 해줘. + +(10-6 사용자 · 추가 요청) + - 페이지의 좌측 패널의 공통항목으로 들어온 버튼 세트의 버튼 크기가 조금 이상함. 점검 From 185e74646eebc8b1e09a76ed816d7fb5bd93dcac Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:02:00 +0900 Subject: [PATCH 15/99] =?UTF-8?q?feat(profile):=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=EC=A0=95=EB=B3=B4=20=EB=9D=BC=EB=B2=A8=20?= =?UTF-8?q?=E2=80=94=20=EB=A7=A8=20=EC=9C=84=20=EC=B8=B5=20=C2=B7=20?= =?UTF-8?q?=E2=96=B2=E2=96=BC=20=EC=9C=84=EB=A1=9C=20=EB=B9=84=ED=82=B4=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=C2=B7=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=EB=A7=88=EB=8B=A4=20=EC=B2=AB=20=EA=B0=92(54-14)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 레인 쌓임 층 풂 · 라벨 z 6 — ▲▼ 단추층 · 고정 Y축 · 테이블 · 유토곡선 위 - 라벨을 그래프 바닥 ▼ 단추 높이만큼 위로 · 위가 잘리면 덜 잘리는 쪽(알약 아래) - 줄 = 종류 이름 · 기준 측점 · 좌측 폼 컨테이너마다 첫 옵션 값 · 빈 값 (기본) · auto (자동) · 메모 뺌 - B05 · B06 공용 레인 한 곳 · 시험 test_54_14_balloon_lines · ORCA 시나리오 54-14_알약_라벨 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MPcuokNm1ZR2kqgGGDfaJA --- B05_Profile/B05_Profile_UI_Profile_Render.ts | 6 + .../B05_Profile_UI_Structures_Balloon.ts | 160 +++++++++++++ .../B05_Profile_UI_Structures_Marks.ts | 88 +++++--- .../B05_Profile_UI_Style_Structures.css | 13 +- .../tester/helper_54_14_balloon_lines.cjs | 121 ++++++++++ resources/tester/test_54_14_balloon_lines.py | 99 +++++++++ resources/tester/ui_checks/54-14_알약_라벨.js | 210 ++++++++++++++++++ 7 files changed, 652 insertions(+), 45 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Structures_Balloon.ts create mode 100644 resources/tester/helper_54_14_balloon_lines.cjs create mode 100644 resources/tester/test_54_14_balloon_lines.py create mode 100644 resources/tester/ui_checks/54-14_알약_라벨.js diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 062d250ed..6dd9a018e 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -52,6 +52,7 @@ import { import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; +import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel"; @@ -335,6 +336,11 @@ export function renderProfile(ctx: ProfileRenderContext): void { ctx.redraw(); }, onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage), + // 라벨 흙막이 상자 수 = 폼과 같은 유출 단 수(54-1 · 54-14) + containerCount: (structure) => + structure.type_id === "pipe" + ? (id) => (id === "soil_add" ? pipeExtraCount(structure.chainage_m ?? null) : undefined) + : undefined, }); // 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고 // 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정). diff --git a/B05_Profile/B05_Profile_UI_Structures_Balloon.ts b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts new file mode 100644 index 000000000..a627c2640 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Structures_Balloon.ts @@ -0,0 +1,160 @@ +/* ============================================================================= + * B05_Profile_UI_Structures_Balloon.ts + * 종단 구조물 알약 정보 라벨 줄(54-14) — 화면 요소 없는 순수 규칙. B05 · B06 알약 레인이 같이 씀. + * + * · 줄 = 종류 이름(기타 = 손 이름) · 기준 측점(구간 = 시작 ~ 끝) · 컨테이너마다 첫 옵션 값 한 줄 + * (「관: 관경 1000」) · 컨테이너가 없으면 앞 두 줄만 · 메모 · 그 밖 옵션은 안 씀 + * · 컨테이너 = 좌측 패널 폼과 같은 상자 — 명세 종류(`STRUCTURE_SPECS`)는 명세 컨테이너(선택 컨테이너는 + * 인스턴스마다) · 그 밖 종류는 정의 옵션 그룹(`StructureType.groups`) + * · 첫 옵션 = 지금 보이는 첫 입력 칸(자동 칸 · 갈래가 없어 가린 칸 · 끈 칸은 건너뜀) + * · 빈 값 = 등록부 기본값 「(기본)」 · 값 `auto` = 「(자동)」(54-13 표시와 같음) · 그것도 없으면 「—」 + * ========================================================================== */ + +import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { bodyOf, boxOptionsIn } from "../A00_Common/design/design_structure_box"; +import { withLegacyGuards } from "../A00_Common/design/design_structure_ford"; +import { + branchOf, + choiceLabel, + choicesOf, + containerTitle, + itemLabel, + readState, + type Instance, + type SpecContainer, + type SpecItem, + type StructureSpec, +} from "../A00_Common/design/design_structure_spec"; + +const DEFAULT_MARK = "(기본)"; +const AUTO_MARK = "(자동)"; +const NONE = "—"; + +/** 선택 컨테이너 개수를 밖에서 정함(배관 흙막이 = 유출 단 수 · 폼 `hooks.count` 와 같은 값). */ +export type ContainerCount = (containerId: string) => number | null | undefined; + +/** 라벨 줄 — [이름, 기준 측점, ...컨테이너 줄]. */ +export function balloonLines( + structure: StructureInstance, + type: StructureType | undefined, + head: { name: string; position: string }, + count?: ContainerCount, +): string[] { + const spec = STRUCTURE_SPECS[structure.type_id]; + const rest = spec + ? specLines(spec, structure, type, count) + : groupLines(structure.options ?? {}, type); + return [head.name, head.position, ...rest]; +} + +/** 명세 컨테이너 줄 — 폼(`Drainage_Facility.setFacility` · `paintStructureSpec`)과 같은 값 · 이름. */ +function specLines( + spec: StructureSpec, + structure: StructureInstance, + type: StructureType | undefined, + count?: ContainerCount, +): string[] { + let names = type?.spec_names ?? {}; + if (structure.type_id === "pipe") { + // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) + const { soil_add: _old, ...pipeNames } = names; + names = pipeNames; + } + const state = formState(spec, structure.type_id, structure.options ?? {}); + // 빈 칸이 그림에서 쓰는 값 — 등록부 기본값을 같은 길로 읽은 필수 컨테이너 값 + const fallback = formState(spec, structure.type_id, registryDefaults(type)); + const groups = Object.fromEntries((type?.groups ?? []).map((g) => [g.id, g.name])); + return spec.containers.flatMap((c) => { + const title = containerTitle(c, names, groups[c.link]); + return instancesOf(c, state[c.id] ?? [], count).map((values, index) => { + const item = c.items.find( + (entry) => entry.kind !== "auto" && (!entry.by || branchOf(entry, values)), + ); + const head = c.required ? title : `${title} ${index + 1}`; + const base = c.required ? (fallback[c.id]?.[0]?.[item?.id ?? ""] ?? "") : ""; + return item + ? `${head}: ${itemLabel(c, item, names)} ${specValue(c, item, values, names, base)}` + : `${head}: ${NONE}`; + }); + }); +} + +/** 저장 값 → 폼 칸 값 — 폼(`Drainage_Facility.setFacility`)과 같은 손질(BOX 옛 설치 · 본체 규격 · + * 세월교 옛 날개벽). */ +function formState( + spec: StructureSpec, + typeId: string, + options: Record, +): Record { + const input = + typeId === "box_culvert" + ? boxOptionsIn(options) + : typeId === "ford_bridge" + ? withLegacyGuards(options) + : options; + const state = readState(spec, input); + // 본체 규격은 저장 안 함 — 폭 · 높이로 되찾음 + if (typeId === "box_culvert") state.body = [{ ...state.body?.[0], ...bodyOf(options) }]; + return state; +} + +/** 보이는 인스턴스 — 필수 = 한 벌 · 선택 = 개수가 정해졌으면 그 수(모자라면 빈 인스턴스) · 아니면 저장된 것. */ +function instancesOf(c: SpecContainer, list: Instance[], count?: ContainerCount): Instance[] { + if (c.required) return [list[0] ?? {}]; + const n = count?.(c.id); + return n === undefined || n === null + ? list + : Array.from({ length: n }, (_, index) => list[index] ?? {}); +} + +function specValue( + c: SpecContainer, + item: SpecItem, + values: Instance, + names: Record, + fallback: string, +): string { + const shown = (raw: string): string => { + const choice = choicesOf(item, values).find((ch) => ch.value === raw); + return choice ? choiceLabel(c, item, choice, names) : raw; + }; + const raw = (values[item.id] ?? "").trim(); + if (raw === "auto") return AUTO_MARK; + if (raw) return shown(raw); + return fallback.trim() ? `${shown(fallback.trim())} ${DEFAULT_MARK}` : NONE; +} + +/** 정의 옵션 그룹 줄 — 그룹마다 첫 옵션(정의에 있고 켜진 칸). */ +function groupLines( + options: Record, + type: StructureType | undefined, +): string[] { + const byKey = new Map((type?.options ?? []).map((option) => [option.key, option])); + return (type?.groups ?? []).map((group) => { + const option = group.options + .map((key) => byKey.get(key)) + .find((entry) => entry && entry.visible !== false && entry.enabled !== false); + if (!option) return `${group.name}: ${NONE}`; + const unit = option.unit ?? ""; + const raw = options[option.key]; + const value = + raw === "auto" + ? AUTO_MARK + : raw !== undefined && raw !== "" + ? `${raw}${unit}` + : option.default !== null && option.default !== undefined && option.default !== "" + ? `${option.default}${unit} ${DEFAULT_MARK}` + : NONE; + return `${group.name}: ${option.label} ${value}`; + }); +} + +/** 등록부 기본값(키 → 값) — 빈 칸이 그림에서 쓰는 값. */ +function registryDefaults(type: StructureType | undefined): Record { + return Object.fromEntries( + (type?.options ?? []) + .filter((option) => option.default !== null && option.default !== undefined) + .map((option) => [option.key, option.default as string | number]), + ); +} diff --git a/B05_Profile/B05_Profile_UI_Structures_Marks.ts b/B05_Profile/B05_Profile_UI_Structures_Marks.ts index 895276fd4..e05b782d7 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Marks.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Marks.ts @@ -20,6 +20,7 @@ import { type StructureType, } from "./B05_Profile_Api_Structures"; import { etcName, numberDuplicateNames } from "./B05_Profile_UI_Structures_List"; +import { balloonLines, type ContainerCount } from "./B05_Profile_UI_Structures_Balloon"; import { stationLabel as formatStation } from "../common_util/common_util_station"; /** 마크를 잡았다고 볼 여유(px) — 시작점에서 이만큼 **벗어나기 전에는** 이동으로 @@ -40,6 +41,8 @@ const LANE_PADDING_PX = 2; const OVERLAP_PX = 52; /** 기타 알약 머리 글자 수 — 약호(3자 이하)와 같은 폭(54-9). */ const ETC_PILL_CHARS = 3; +/** 라벨을 위로 비키는 높이(px) — 그래프 바닥 ▼ 단추(바닥 2 + 높이 17) + 틈 2(54-14). */ +const EDIT_BUTTON_CLEARANCE_PX = 21; /** 레인 전체 높이(px) — 2행 구조를 항상 확보한다(항목이 하나여도 높이는 같다). * 큰(혼자) 알약도 이 안에 들어간다. 아래 서브패널 손잡이와의 사이는 CSS 여백을 @@ -64,18 +67,8 @@ export interface StructureMarksOptions { selectedId: string | null; onSelect: (structureId: string | null) => void; onMove: (structureId: string, toChainageM: number) => void; -} - -function optionSummary(structure: StructureInstance, type: StructureType | undefined): string { - if (!type) return ""; - return type.options - .map((option) => { - const value = structure.options?.[option.key]; - if (value === undefined || value === "") return null; - return `${option.label} ${value}${option.unit ?? ""}`; - }) - .filter(Boolean) - .join(" · "); + /** 라벨 선택 컨테이너 개수(폼과 같은 값 · 배관 흙막이 = 유출 단 수) — 없으면 저장된 것. */ + containerCount?: (structure: StructureInstance) => ContainerCount | undefined; } /** 위치 표기 — 측점번호+잔여거리(예: 3+18.0). 누가거리 표기는 쓰지 않는다(2026-08-17). */ @@ -236,7 +229,17 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement if (selected) lane.append( - buildBalloon(structure, type, anchorPx, row, options.stationIntervalM, displayName), + buildBalloon( + balloonLines( + structure, + type, + { name: displayName, position: positionText(structure, options.stationIntervalM) }, + options.containerCount?.(structure), + ), + type, + anchorPx, + row, + ), ); }); @@ -259,44 +262,57 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement /** 고른 구조물의 값 벌룬. 유토곡선 벌룬과 같은 결(도형+텍스트)로 맞춘다. * 알약 **위**로 띄운다 — 아래로 펴면 도면 테이블에 가려 안 보인다(2026-08-18 * 사용자 지시). 2행 구조에서 1행(윗줄)은 좌측 위, 2행(아랫줄)은 우측 위로 - * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. */ + * 전개해 서로 겹치지 않게 하고, 솔로 알약은 위 중앙이다. + * 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값(`balloonLines` · 54-14) · 그래프 바닥 ▼ 단추를 가리지 + * 않게 단추 높이만큼 더 위 · 위가 잘리면 알약 아래로. */ function buildBalloon( - structure: StructureInstance, + lines: string[], type: StructureType | undefined, anchorPx: number, row: number | null, - stationIntervalM: number, - /** 중복 번호가 붙은 표시 이름("옹벽 1") — 좌측 목록과 같은 표기(2026-08-19). */ - displayName?: string, ): HTMLElement { const balloon = document.createElement("div"); balloon.className = "b05-structure__balloon"; balloon.classList.add(row === 0 ? "is-left" : row === 1 ? "is-right" : "is-center"); balloon.style.left = `${anchorPx}px`; - balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; + balloon.style.bottom = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX}px`; balloon.style.borderColor = type?.style?.color ?? "#888"; + const [name, ...rest] = lines; const title = document.createElement("strong"); - title.textContent = displayName ?? type?.name ?? structure.type_id; - const position = document.createElement("span"); - position.textContent = positionText(structure, stationIntervalM); - balloon.append(title, position); - - const summary = optionSummary(structure, type); - if (summary) { - const detail = document.createElement("span"); - detail.textContent = summary; - balloon.append(detail); - } - if (structure.memo) { - const memo = document.createElement("span"); - memo.className = "b05-structure__balloon-memo"; - memo.textContent = structure.memo; - balloon.append(memo); - } + title.textContent = name; + balloon.append( + title, + ...rest.map((line) => { + const span = document.createElement("span"); + span.textContent = line; + return span; + }), + ); + requestAnimationFrame(() => flipIfClipped(balloon)); return balloon; } +/** 라벨 위가 스크롤 상자 · 창 밖으로 잘리면 알약 아래로 편다(아래가 덜 잘릴 때만). */ +function flipIfClipped(balloon: HTMLElement): void { + const lane = balloon.parentElement; + if (!balloon.isConnected || !lane) return; + let top = 0; + let bottom = window.innerHeight; + for (let at: HTMLElement | null = lane; at; at = at.parentElement) + if (getComputedStyle(at).overflowY !== "visible") { + const clip = at.getBoundingClientRect(); + top = Math.max(top, clip.top); + bottom = Math.min(bottom, clip.bottom); + } + const box = balloon.getBoundingClientRect(); + const hiddenAbove = top - box.top; + const hiddenBelow = lane.getBoundingClientRect().bottom + ROW_GAP_PX + box.height - bottom; + if (hiddenAbove <= 0 || hiddenBelow >= hiddenAbove) return; + balloon.style.bottom = "auto"; + balloon.style.top = `${STRUCTURE_LANE_HEIGHT_PX + ROW_GAP_PX}px`; +} + /** 마크를 좌우로 끌어 위치를 옮긴다. 구간형은 길이를 유지한 채 통째로 움직인다. */ function attachMarkDrag( mark: HTMLElement, diff --git a/B05_Profile/B05_Profile_UI_Style_Structures.css b/B05_Profile/B05_Profile_UI_Style_Structures.css index 65ba8eb49..126a19b9f 100644 --- a/B05_Profile/B05_Profile_UI_Style_Structures.css +++ b/B05_Profile/B05_Profile_UI_Style_Structures.css @@ -6,11 +6,11 @@ * ========================================================================== */ /* 구조물 알약 레인 — 그래프 바로 아래 한 줄(2026-08-17 사용자 지시 1·3·6). - 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. */ + 높이는 2행분 고정이고, 겹치지 않으면 알약이 세로 가운데에 놓인다. + z-index 를 두지 않음 — 쌓임 층을 만들면 라벨이 그 안에 갇혀 ▲▼ 단추층(4) 밑으로 감(54-14). */ .b05-structure__lane { position: relative; overflow: visible; - z-index: 3; } /* 레인 왼쪽 Y축 띠 — 그래프 sticky Y축과 같은 폭·같은 배경으로 이어 붙여, 가로 @@ -105,7 +105,8 @@ /* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */ /* 벌룬은 알약 위로 뜬다(2026-08-18) — 아래는 도면 테이블에 가린다. 전개 방향: - 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). */ + 1행 알약 = 좌측 위(is-left), 2행 = 우측 위(is-right), 솔로 = 위 중앙(is-center). + 층 = 종단 패널 맨 위 — ▲▼ 단추층(4) · 고정 Y축 · 테이블 · 유토곡선(5) 위 · 우클릭 메뉴(7) 아래(54-14). */ .b05-structure__balloon { position: absolute; z-index: 6; @@ -122,7 +123,6 @@ line-height: 1.35; white-space: nowrap; pointer-events: none; - z-index: 4; } .b05-structure__balloon.is-left { @@ -138,11 +138,6 @@ font-size: 11.5px; } -.b05-structure__balloon-memo { - opacity: 0.7; - font-style: italic; -} - /* ── 계곡 통과 시설 부속 옵션 서브폼 (구조물 배치 폼 안, 2026-08-17 UI 개편) ── * 배관·BOX암거·세월교·독립 기슭막이의 부속 옵션을 2열 격자로 담는다. */ .b05-drainage__facility { diff --git a/resources/tester/helper_54_14_balloon_lines.cjs b/resources/tester/helper_54_14_balloon_lines.cjs new file mode 100644 index 000000000..5816c7935 --- /dev/null +++ b/resources/tester/helper_54_14_balloon_lines.cjs @@ -0,0 +1,121 @@ +/* 54-14 종단 알약 정보 라벨 줄 단위검증 헬퍼 — TS 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * 결과는 표준출력에 JSON 한 벌. 화면 그릇은 쓰지 않는다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_structures_form_summary.cjs` 머리와 같다. + */ +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(); + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const { balloonLines } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Balloon.ts"), +); + +const opt = (key, label, extra = {}) => ({ key, label, default: null, unit: null, ...extra }); +const TYPES = { + pipe: { + type_id: "pipe", + name: "배관", + options: [ + opt("pipe_diameter_mm", "관경", { default: "1000" }), + opt("inlet_type", "유입", { default: "기슭막이" }), + ], + groups: [ + { id: "grv6lny", name: "관", options: [] }, + { id: "gnbf9m9", name: "관 보호공(유입)", options: [] }, + { id: "g9lq9uk", name: "관 보호공(유출)", options: [] }, + ], + spec_names: { soil_add: "흙막이 추가" }, + }, + box_culvert: { + type_id: "box_culvert", + name: "BOX암거", + options: [ + opt("body_width_m", "폭", { default: 2 }), + opt("body_height_m", "높이", { default: 2 }), + ], + groups: [], + }, + cross_underdrain: { + type_id: "cross_underdrain", + name: "맹암거", + options: [ + opt("culvert_height_m", "높이", { unit: "m" }), + opt("culvert_slope_pct", "기울기", { unit: "%" }), + ], + groups: [ + { id: "gcrossud", name: "맹암거", options: ["culvert_height_m", "culvert_slope_pct"] }, + ], + }, + retaining_wall: { + type_id: "retaining_wall", + name: "옹벽", + options: [ + opt("height_m", "높이", { default: 2, unit: "m", visible: false }), + opt("length_m", "길이", { default: 10, unit: "m" }), + ], + groups: [{ id: "gretaindetail", name: "상세 내용", options: ["height_m", "length_m"] }], + }, + mirror: { type_id: "mirror", name: "반사경", options: [], groups: [] }, + etc: { type_id: "etc", name: "기타", options: [opt("name", "이름")], groups: [] }, +}; +const point = (type_id, options = {}) => ({ + structure_id: type_id, + type_id, + placement: "point", + chainage_m: 100, + options, + memo: "메모는 안 씀", +}); +const head = (name) => ({ name, position: "5+0" }); +const run = (structure, count) => + balloonLines(structure, TYPES[structure.type_id], head("이름"), count); + +const out = { + pipeFull: run( + point("pipe", { + pipe_diameter_mm: 1200, + inlet_type: "집수정", + inlet_structure: "L", + outlet_type: "기슭막이", + soil_add_1_structure: "기슭막이", + soil_add_1_form: "돌망태", + chute_add_1_structure: "도수로", + }), + ), + pipeBlank: run(point("pipe")), + // 흙막이 개수 = 밖에서 정한 단 수(2) — 저장 1 벌 · 모자란 둘째는 빈 상자 + pipeCount: run(point("pipe", { soil_add_1_structure: "기슭막이" }), (id) => + id === "soil_add" ? 2 : undefined, + ), + boxPreset: run(point("box_culvert", { body_width_m: 3, body_height_m: 3, wing_in: "있음" })), + boxBlank: run(point("box_culvert")), + underdrain: run(point("cross_underdrain", { culvert_height_m: 0.6 })), + underdrainBlank: run(point("cross_underdrain")), + wall: run(point("retaining_wall")), + wallAuto: run(point("retaining_wall", { length_m: "auto" })), + mirror: run(point("mirror", { anything: 1 })), + etcEmpty: run(point("etc", { name: "안내판" })), + etcNote: run(point("etc", { name: "안내판", etc_detail_1_note: "시험 비고" })), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_54_14_balloon_lines.py b/resources/tester/test_54_14_balloon_lines.py new file mode 100644 index 000000000..6374f7903 --- /dev/null +++ b/resources/tester/test_54_14_balloon_lines.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""54-14 종단 구조물 알약 정보 라벨 — 중요한 것만. + +• 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 옵션 값 한 줄(컨테이너가 없으면 앞 두 줄만) +• 컨테이너 = 좌측 패널 폼과 같은 상자(명세 컨테이너 · 선택 컨테이너는 인스턴스마다 · 정의 옵션 그룹) +• 빈 값 = 등록부 기본값 「(기본)」 · 값 auto = 「(자동)」 · 그것도 없으면 「—」 · 메모 · 그 밖 옵션은 안 씀 +• 라벨은 ▲▼ 단추층 위 · 단추 높이만큼 위로 비킴 · B05 · B06 은 같은 레인 — 실제 화면은 ORCA 로 따로 본다. +화면 그릇 없이 Node 헬퍼가 TS 를 트랜스파일해 돌린다(`helper_54_14_balloon_lines.cjs`). +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, "..", "..")) +MARKS = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Structures_Marks.ts") +STYLE = os.path.join(ROOT, "B05_Profile", "B05_Profile_UI_Style_Structures.css") + + +@pytest.fixture(scope="module") +def out(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_54_14_balloon_lines.cjs")], + 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["pipeFull"] == [ + "이름", + "5+0", + "관: 관경 1200", + "관 보호공(유입): 구조 집수정", + "관 보호공(유출): 구조 흙막이", + "흙막이 1: 구조 흙막이", + "도수로/돌붙임 1: 구조 도수로", + ] + + +def test_빈_값은_등록부_기본값에_기본_표시(out): + assert out["pipeBlank"] == [ + "이름", + "5+0", + "관: 관경 1000 (기본)", + "관 보호공(유입): 구조 흙막이 (기본)", + "관 보호공(유출): 구조 —", + ] + + +def test_흙막이_상자_수는_밖에서_정한_단_수(out): + assert out["pipeCount"][-2:] == ["흙막이 1: 구조 흙막이", "흙막이 2: 구조 —"] + assert len(out["pipeCount"]) == 2 + 3 + 2 + + +def test_박스_본체_규격은_폭_높이로_되찾음(out): + assert out["boxPreset"] == [ + "이름", + "5+0", + "본체: 본체 규격 3.0 × 3.0", + "유입 구조: 구조 날개벽", + "유출 구조: 구조 —", + ] + assert out["boxBlank"][2] == "본체: 본체 규격 2.0 × 2.0 (기본)" + + +def test_정의_옵션_그룹은_그룹마다_첫_켜진_옵션(out): + assert out["underdrain"] == ["이름", "5+0", "맹암거: 높이 0.6m"] + assert out["underdrainBlank"] == ["이름", "5+0", "맹암거: 높이 —"] + # 끈 칸(높이)은 건너뜀 · 빈 값은 기본값 + assert out["wall"] == ["이름", "5+0", "상세 내용: 길이 10m (기본)"] + assert out["wallAuto"] == ["이름", "5+0", "상세 내용: 길이 (자동)"] + + +def test_컨테이너가_없으면_이름과_기준_측점만(out): + assert out["mirror"] == ["이름", "5+0"] + assert out["etcEmpty"] == ["이름", "5+0"] + assert out["etcNote"] == ["이름", "5+0", "상세 내용 1: 비고 시험 비고"] + + +def test_라벨은_단추층_위로_비켜_뜬다(): + with open(MARKS, encoding="utf-8") as handle: + marks = handle.read() + with open(STYLE, encoding="utf-8") as handle: + style = handle.read() + assert "EDIT_BUTTON_CLEARANCE_PX = 21" in marks + assert "ROW_GAP_PX + EDIT_BUTTON_CLEARANCE_PX" in marks + assert "balloonLines(" in marks and "structure.memo" not in marks + lane = style.split(".b05-structure__lane {", 1)[1].split("}", 1)[0] + assert "z-index" not in lane # 레인이 쌓임 층을 만들면 라벨이 그 안에 갇힘 + balloon = style.split(".b05-structure__balloon {", 1)[1].split("}", 1)[0] + assert balloon.count("z-index") == 1 and "z-index: 6;" in balloon diff --git a/resources/tester/ui_checks/54-14_알약_라벨.js b/resources/tester/ui_checks/54-14_알약_라벨.js new file mode 100644 index 000000000..1e04a6b09 --- /dev/null +++ b/resources/tester/ui_checks/54-14_알약_라벨.js @@ -0,0 +1,210 @@ +/* 54-14 — 종단도 구조물 알약 정보 라벨: 맨 위 층 · ▲▼ 단추를 안 가림 · 줄 = 이름 · 기준 측점 · 컨테이너마다 첫 값 + * 시작 화면: B05 종단설계(#/b05-profile) · 검증 프로젝트(데스크탑_메인) · 로그인 · 종단 도크에 배관 1(4+5) · 배관 3(22+0) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5414 = "단계";` + * seed → 처음 초안(세션 원문)을 `__5414_orig` 에 남김 · 구조물 초안에 맹암거 · 반사경 · 기타(안내판 · 비고) 심음 + * → 그 뒤 orca reload + * box → 배관 3(22+0) 종류를 BOX암거로(관 초안만 · 저장 안 함) + * check → 배관 · 박스 · 맹암거 · 반사경 · 기타 알약을 눌러 라벨 판정 — B05 에서 한 번(값을 세션에 둠) · + * B06(#/b06-section) 에서 한 번(B05 = B06) + * restore → 처음 초안 원문으로 되돌림 → 그 뒤 orca reload + * 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__5414_out_{단계}")` 로 읽음 + * 판정: 라벨 위 점 15 곳 모두 맨 위 = 라벨 · 라벨 상자와 ▲▼ 단추 상자 겹침 0 · 줄 수 = 1 + 1 + 컨테이너 수 + * (컨테이너 수 = 좌측 패널 폼에 보이는 상자 · 줄 머리 = 상자 제목) · B05 = B06 + * 쓰는 것: sessionStorage(구조물 · 관 초안) — 정본(서버)은 안 씀 + */ +(async () => { + const MODE = window.__m5414 || "check"; + 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 pid = 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 txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const visible = (n) => n.offsetParent !== null; + const DRAFTS = /^aislo:draft:(structures|pipes):/; + const b06 = location.hash.includes("b06"); + const out = { mode: MODE, page: location.hash, fails: [] }; + const fail = (what) => out.fails.push(what); + const marks = () => [...document.querySelectorAll(".b05-structure__mark")]; + const TARGETS = { + pipe: (m) => m.dataset.structureId === "pipe-85.05", + box: (m) => m.dataset.structureId === "pipe-440.24", + underdrain: (m) => m.title.startsWith("맹암거"), + mirror: (m) => m.title.startsWith("반사경"), + etc: (m) => m.title.startsWith("안내판"), + }; + const openDock = async () => { + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + if (handle?.getAttribute("aria-expanded") !== "true") handle?.click(); + await until(() => marks().length > 0, 30000); + }; + + if (MODE === "seed") { + const orig = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + sessionStorage.setItem("__5414_orig", JSON.stringify(orig)); + const types = Object.fromEntries((await api.fetchStructureTypes()).map((t) => [t.type_id, t])); + const base = api.readPendingStructures(pid) ?? (await api.fetchStructures(pid)).structures; + const add = (id, type_id, at, options) => { + const interval = types[type_id].placement === "interval"; + return { + structure_id: id, + type_id, + placement: types[type_id].placement, + chainage_m: at, + start_m: interval ? at - 5 : null, + end_m: interval ? at + 5 : null, + options, + memo: "54-14 메모(라벨에 안 나옴)", + placement_source: "manual", + status: "draft", + revision: 0, + geometry: null, + }; + }; + api.writePendingStructures(pid, [ + ...base.filter((s) => !String(s.structure_id).startsWith("t5414")), + add("t5414ud", "cross_underdrain", 600, { culvert_height_m: 0.6 }), + add("t5414mr", "mirror", 660, {}), + add("t5414etc", "etc", 720, { name: "안내판", etc_detail_1_note: "시험 비고" }), + ]); + out.seeded = api.readPendingStructures(pid).length - base.length; + } + + if (MODE === "box") { + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const row = () => rows().find((li) => /^22\+0 (배관|BOX암거)/.test(txt(li))); + await until(() => row(), 30000); + if (!row().classList.contains("is-selected")) row().click(); + await w(1500); + const sel = document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + sel.value = "box_culvert"; + sel.dispatchEvent(new Event("change", { bubbles: true })); + await openDock(); + await until(() => marks().some((m) => TARGETS.box(m) && m.title.startsWith("BOX암거"))); + out.box = marks().find(TARGETS.box)?.title ?? null; + if (!out.box?.startsWith("BOX암거")) fail(`박스 알약 ${out.box}`); + row()?.click(); // 고른 것 풂 + await w(800); + } + + if (MODE === "check") { + await openDock(); + const body = document.querySelector(".b05-structure__lane").closest("[class*='__body']"); + const r = {}; + for (const [key, find] of Object.entries(TARGETS)) { + const mark = marks().find(find); + if (!mark) { + fail(`${key}: 알약 없음`); + continue; + } + mark.scrollIntoView({ inline: "center", block: "nearest" }); + await w(400); + const now = marks().find(find); + if (!now.classList.contains("is-selected")) now.click(); + await until(() => document.querySelector(".b05-structure__balloon")); + await w(1200); // 좌측 폼 · 레인 다시 그림 + const balloon = document.querySelector(".b05-structure__balloon"); + const box = balloon.getBoundingClientRect(); + const lines = [...balloon.children].map((c) => c.textContent); + // ① 맨 위 층 — 라벨 위 15 점에서 가장 위 요소가 라벨(재는 동안만 누름 받기 켬) + balloon.style.pointerEvents = "auto"; + let top = 0; + for (const fx of [0.1, 0.3, 0.5, 0.7, 0.9]) + for (const fy of [0.2, 0.5, 0.8]) { + const hit = document.elementFromPoint( + box.left + box.width * fx, + box.top + box.height * fy, + ); + if (hit && balloon.contains(hit)) top += 1; + } + balloon.style.pointerEvents = ""; + // ② ▲▼ 단추와 안 겹침(실좌표) · 바로 아래 ▼ 단추와 틈 + const buttons = [...document.querySelectorAll(".b05-profile-edit__btn")].map((b) => ({ + down: b.classList.contains("is-down"), + rect: b.getBoundingClientRect(), + })); + const overlap = buttons.filter( + ({ rect }) => + rect.left < box.right && + rect.right > box.left && + rect.top < box.bottom && + rect.bottom > box.top, + ).length; + const under = buttons.filter( + ({ down, rect }) => down && rect.left < box.right && rect.right > box.left, + ); + const gap = under.length ? Math.min(...under.map(({ rect }) => rect.top)) - box.bottom : null; + // ③ 줄 수 = 1 + 1 + 좌측 폼 상자 수 · 줄 머리 = 상자 제목 + const legends = [ + ...document.querySelectorAll("fieldset[data-spec-container], [data-b05-group]"), + ] + .filter(visible) + .map((f) => { + const legend = f.querySelector("legend").cloneNode(true); + legend.querySelectorAll("button").forEach((b) => b.remove()); + return legend.textContent.trim(); + }); + const heads = lines.slice(2).map((l) => l.slice(0, l.indexOf(": "))); + r[key] = { + lines, + top, + overlap, + gap, + boxes: legends.length, + z: getComputedStyle(balloon).zIndex, + clipTop: body ? box.top - body.getBoundingClientRect().top : null, + }; + if (top !== 15) fail(`${key}: 맨 위 아님 ${top}/15`); + if (overlap) fail(`${key}: 단추 겹침 ${overlap}`); + if (lines.length !== 2 + legends.length) + fail(`${key}: 줄 ${lines.length} ≠ 2 + 상자 ${legends.length}`); + if (JSON.stringify(heads) !== JSON.stringify(legends)) + fail(`${key}: 줄 머리 ${heads} ≠ 상자 ${legends}`); + if (lines.some((l) => l.includes("메모"))) fail(`${key}: 메모 줄`); + marks().find(find).click(); // 고른 것 풂 + await w(800); + } + out.read = r; + const lineMap = JSON.stringify( + Object.fromEntries(Object.entries(r).map(([k, v]) => [k, v.lines])), + ); + if (!b06) sessionStorage.setItem("__5414_b05", lineMap); + else { + out.sameAsB05 = sessionStorage.getItem("__5414_b05") === lineMap; + if (!out.sameAsB05) fail("B05 ≠ B06"); + } + } + + if (MODE === "restore") { + const orig = JSON.parse(sessionStorage.getItem("__5414_orig") || "null"); + if (!orig) return JSON.stringify({ error: "원래 값 없음" }); + for (const k of Object.keys(sessionStorage)) if (DRAFTS.test(k)) sessionStorage.removeItem(k); + for (const [k, v] of Object.entries(orig)) sessionStorage.setItem(k, v); + const now = Object.fromEntries( + Object.keys(sessionStorage) + .filter((k) => DRAFTS.test(k)) + .map((k) => [k, sessionStorage.getItem(k)]), + ); + out.restored = JSON.stringify(now) === JSON.stringify(orig); + for (const k of Object.keys(sessionStorage)) + if (k.startsWith("__5414_")) sessionStorage.removeItem(k); + } + const result = JSON.stringify(out); + if (MODE !== "restore") sessionStorage.setItem(`__5414_out_${MODE}`, result); + return result; +})(); From 8aea7b8cbf77b16b2c1e32e21ba71ca46e95bf35 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:03:59 +0900 Subject: [PATCH 16/99] =?UTF-8?q?fix(structure):=20=EB=B9=88=20=C2=B7=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B9=B8?= =?UTF-8?q?=EC=97=90=20=EA=B7=B8=EB=A6=BC=EC=9D=B4=20=EC=93=B0=EB=8A=94=20?= =?UTF-8?q?=EA=B0=92=20=ED=91=9C=EC=8B=9C(54-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 명세 폼 고르기 칸 — 비었거나 자동 규칙이면 빈 보기 자리에 「값 (기본)」 · 「값 (자동)」 · 고른 값과 구별 · 저장 값 그대로 - 배관: 관경 · 유출 구조 = 정의 기본값 · 유입 구조 · 집수정 모양 · 벽 형태 = 횡단 기하 결과(세션 유입 형식 반영) - 옛 「자동(규칙)」(유입 기슭막이 + 형식 없음) = 「집수정 (자동)」 등 그림 결과 · 유입 구조를 고를 때까지 형식 revet 안 굳음 - 그림 값을 보이던 위 칸 아래를 고치면 그 위 값을 고른 것(집수정 높이 → 집수정) - 세월교 관경 · 유입 · 유출 / BOX 규격 · 유입 · 유출 = 정의 기본값(날개벽 설치 규칙 = 엔진과 같음) - 정의 그룹 꼴 칸 — 빈 칸에 정의 기본값이 있으면 「값 (기본)」 - 시험: ui_checks/54-13_그림값_표시.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- A00_Common/design/design_structure_form.ts | 58 +++- .../design/design_structure_pipe_calc.ts | 35 +++ A00_Common/design/design_view_cross.ts | 3 + .../B05_Profile_UI_Drainage_Facility.ts | 95 ++++++- .../B05_Profile_UI_Structures_Fields.ts | 9 +- .../tester/ui_checks/54-13_그림값_표시.js | 255 ++++++++++++++++++ 6 files changed, 441 insertions(+), 14 deletions(-) create mode 100644 resources/tester/ui_checks/54-13_그림값_표시.js diff --git a/A00_Common/design/design_structure_form.ts b/A00_Common/design/design_structure_form.ts index 4adf4a6c4..875291406 100644 --- a/A00_Common/design/design_structure_form.ts +++ b/A00_Common/design/design_structure_form.ts @@ -59,6 +59,14 @@ export interface SpecFormHooks { * [추가] · [빼기] 없음 · 줄면 끝에서부터 가림(값은 남음 · 54-10) · null = 모름(저장된 것 그대로) · * undefined = 손 [추가]. */ count?: (c: SpecContainer) => number | null | undefined; + /** 그림이 쓰는 값(54-13) — 고르기 칸이 비었거나 자동 규칙일 때 그 값 · 꼬리(「기본」 · 「자동」) · + * null = 없음. 보여 주기만(값 · 저장은 사용자가 고를 때만). `values` = 보이는 값(위 칸 반영). */ + shown?: ( + c: SpecContainer, + item: SpecItem, + values: Record, + index: number, + ) => { value: string; tag: string } | null; } export interface SpecForm { @@ -105,18 +113,27 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return slot; }; - const control = (c: SpecContainer, item: SpecItem, values: Instance): HTMLElement => { + const control = ( + c: SpecContainer, + item: SpecItem, + values: Instance, + tag?: string, + ): HTMLElement => { if (item.kind === "select") { const select = document.createElement("select"); const choices = choicesOf(item, values); + const now = values[item.id] ?? ""; + const drawn = choices.find((ch) => ch.value === now); + // 그림이 쓰는 값은 빈 보기 자리에 꼬리를 달아 보임 — 고른 값과 구별(54-13) + const name = drawn ? choiceLabel(c, item, drawn, names) : now; + const blank = !tag ? "— 안 정함 —" : name ? `${name} (${tag})` : tag; select.replaceChildren( - new Option("— 안 정함 —", ""), + new Option(blank, ""), ...choices.map((ch) => new Option(choiceLabel(c, item, ch, names), ch.value)), ); - const now = values[item.id] ?? ""; // 선택지 밖 옛 값은 그 값만 남겨 보임(지운 값을 몰래 바꾸지 않음) - if (now && !choices.some((ch) => ch.value === now)) select.add(new Option(now, now)); - select.value = now; + if (!tag && now && !drawn) select.add(new Option(now, now)); + select.value = tag ? "" : now; return select; } if (item.kind === "text") { @@ -139,7 +156,19 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { return stepper(input, item.step ?? 0.1); }; - const onEdit = (c: SpecContainer, item: SpecItem, values: Instance, raw: string): void => { + const onEdit = ( + c: SpecContainer, + item: SpecItem, + values: Instance, + raw: string, + view: Instance, + ): void => { + // 그림 값을 보이던 위 칸 아래를 고침 = 그 위 값을 고른 것 — 다른 갈래 값은 비움(54-13) + const parent = item.by?.item; + if (parent && view[parent] !== values[parent]) { + values[parent] = view[parent]; + for (const child of c.items) if (child.by?.item === parent) values[child.id] = ""; + } values[item.id] = raw; if (item.split) { const order = [item.split, ...(recent.get(values) ?? []).filter((r) => r !== item.split)]; @@ -170,15 +199,26 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = { const title = containerTitle(c, names, groupNames[c.link]); const box = group(c.required ? title : `${title} ${index + 1}`); box.root.dataset.specContainer = c.id; + // 보이는 값 = 고른 값 + 그림이 쓰는 값(54-13) · 위 칸이 다른 갈래를 보이면 아래 칸은 빈 채로 + const view: Instance = { ...values }; + const tags = new Map(); + for (const item of c.items) { + if (item.by && view[item.by.item] !== values[item.by.item]) view[item.id] = ""; + const drawn = item.kind === "select" ? hooks.shown?.(c, item, view, index) : null; + if (!drawn) continue; + view[item.id] = drawn.value; + tags.set(item.id, drawn.tag); + } const fields = c.items - .filter((item) => item.kind === "auto" || !item.by || branchOf(item, values)) + .filter((item) => item.kind === "auto" || !item.by || branchOf(item, view)) .map((item) => { - const input = control(c, item, values); + const input = control(c, item, view, tags.get(item.id)); const wrap = labeled(itemLabel(c, item, names), input); wrap.dataset.specItem = `${c.id}.${item.id}`; + if (tags.has(item.id)) wrap.dataset.specShown = tags.get(item.id); const target = input.querySelector?.("input, select") ?? input; target.addEventListener("change", () => - onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value), + onEdit(c, item, values, (target as HTMLInputElement | HTMLSelectElement).value, view), ); return wrap; }); diff --git a/A00_Common/design/design_structure_pipe_calc.ts b/A00_Common/design/design_structure_pipe_calc.ts index 6e60c27bf..357ab0857 100644 --- a/A00_Common/design/design_structure_pipe_calc.ts +++ b/A00_Common/design/design_structure_pipe_calc.ts @@ -25,6 +25,13 @@ export function setPipeExtraCountSource(source: (section: CrossSection) => numbe extraCountSource = source; } +/** 유입 형식을 대는 곳 — 횡단 보기의 세션 값 우선(`valueFor`) · 없으면 정본 patch(54-13). */ +type InletChoice = NonNullable["inlet_structure"]; +let inletSource: ((section: CrossSection) => InletChoice) | null = null; +export function setPipeInletSource(source: (section: CrossSection) => InletChoice): void { + inletSource = source; +} + /** 그 관의 주인 측점(관 자리에서 가장 가까운 측점) — 횡단 자료가 없으면 null. */ function ownerSection( chainageM: number | null, @@ -64,6 +71,34 @@ export function pipeExtraCount(chainageM: number | null): number | null { return computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet; } +/** 그림이 쓰는 유입 · 유출 값(54-13) — 집수정 모양(없으면 null = 흙막이 벽) · 벽 형태(유입 · 유출 · + * 흙막이 n). 폼 빈 · 자동 칸에 보여 주기만. 횡단 자료가 없으면 null(모름). */ +export function pipeDrawn(chainageM: number | null): { + basin: string | null; + forms: Record; + extras: Array; +} | null { + const found = ownerSection(chainageM); + const design = found?.owner.design; + if (!found || !design) return null; + const { owner, sections } = found; + const outlet = extraCountSource?.(owner) ?? design.extra_wall_counts?.outlet ?? 0; + const counts = { basin: 0, ...design.extra_wall_counts, outlet }; + const inlet = inletSource?.(owner) ?? design.inlet_structure; + const probe = { + ...owner, + design: { ...design, extra_wall_counts: counts, inlet_structure: inlet }, + }; + const layout = computeStoredLayouts(probe, sections)?.culvert; + if (!layout) return null; + const formOf = (role: string) => layout.walls.find((w) => w.role === role)?.form ?? null; + return { + basin: layout.basin?.shape ?? null, + forms: { inlet: formOf("inlet"), outlet: formOf("outlet") }, + extras: layout.extraWalls.map((w) => w.form), + }; +} + /** 흙막이 높이 빈 칸 안내 — 관경 기준 최소 높이(엔진이 빈 칸을 이 값으로 그림). */ export function guardHeightHint(diameterMm: string): string { const d = Number(diameterMm || "1000") / 1000; diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 065766020..0ce481802 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -76,6 +76,7 @@ import type { DesignPageHost } from "./design_page"; import { pipeExtraCount, setPipeExtraCountSource, + setPipeInletSource, setPipeSectionsSource, } from "./design_structure_pipe_calc"; import { @@ -296,6 +297,8 @@ export async function mountCrossView( }); // 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선 setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet")); + // 폼이 보이는 유입 그림 값(54-13) — 세션 형식 우선 + setPipeInletSource((section) => stationControls.inletStructure.valueFor(section)); const sectionView = createSectionView( (chainageM, change) => { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 35ee3c8ea..15d55e4c7 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -46,6 +46,7 @@ import { WING_OUT_KEYS, } from "./B05_Profile_UI_Drainage_Facility_Fields"; import { createSpecForm, type SpecLinks } from "../A00_Common/design/design_structure_form"; +import { branchOf } from "../A00_Common/design/design_structure_spec"; import { PIPE_SPEC } from "../A00_Common/design/design_structure_pipe"; import { BOX_SPEC, @@ -57,6 +58,7 @@ import { FORD_SPEC, withLegacyGuards } from "../A00_Common/design/design_structu import { fordHeightHint, fordHeightM } from "../A00_Common/design/design_structure_ford_calc"; import { guardHeightHint, + pipeDrawn, pipeExtraCount, pipeLengthM, } from "../A00_Common/design/design_structure_pipe_calc"; @@ -212,6 +214,8 @@ export interface FacilityOptionsForm { definition?: { spec_names?: Record; groups?: Array<{ id: string; name: string }>; + /** 옵션 정의 기본값 — 빈 고르기 칸이 그림 값을 보임(54-13). */ + options?: Array<{ key: string; default: unknown }>; } | null, ) => void; /** 배관 명세 컨테이너 「구조물 조합」 잇기 자리 — 폼이 패널 그룹 값에 이음(PLAN 52-1). */ @@ -267,7 +271,52 @@ export function createFacilityOptionsForm( clear: (key) => linkSource?.clear(key), set: (key, item) => linkSource?.set(key, item), }; + // 그림이 쓰는 값(54-13) — 빈 고르기 칸 = 정의 기본값(엔진 · 횡단도가 그 값으로 그림) · 보여 주기만 + let defaults: Record = {}; + const byDefault = (key: string | undefined): { value: string; tag: string } | null => { + const value = key ? defaults[key] : null; + return value === null || value === undefined || value === "" + ? null + : { value: String(value), tag: "기본" }; + }; + /** 빈 날개벽 설치 = 정의 기본 설치(옛 「있음」 · 빈 값 = 날개벽 · 엔진 `_wing_spec`). */ + const wingDefault = (key: string): { value: string; tag: string } => { + const value = String(defaults[key] ?? ""); + return { value: ["없음", "흙막이"].includes(value) ? value : "날개벽", tag: "기본" }; + }; + // 유입 「자동(규칙)」 = 기슭막이 + 유입 형식 auto(옛 값 · 정의 기본) — 그림은 지형 규칙이 집수정 · 흙막이를 + // 정함. 사용자가 유입 구조를 고를 때까지 저장도 그대로(흙막이 고름의 형식 revet 을 안 적음) + let inletAuto: { structure?: string | number } | null = null; + const inletIsAuto = (): boolean => + !!inletAuto && pipeForm.state().inlet?.[0]?.structure === "기슭막이"; + // 그림 계산은 한 번 그릴 동안 한 번만(칸마다 부름) + let drawnMemo: ReturnType | undefined; + const drawnNow = (): ReturnType => { + if (drawnMemo === undefined) { + drawnMemo = pipeDrawn(chainage); + queueMicrotask(() => (drawnMemo = undefined)); + } + return drawnMemo; + }; const pipeForm = createSpecForm(specLinks, { + shown: (c, item, values, index) => { + if (c.id === "inlet" && item.id === "structure" && (!values.structure || inletIsAuto())) { + const drawn = drawnNow(); + return drawn + ? { value: drawn.basin ? "집수정" : "기슭막이", tag: "자동" } + : { value: "", tag: "자동(지형 규칙)" }; + } + if (values[item.id]) return null; + if (item.id !== "form") return byDefault(branchOf(item, values)?.key); + const basin = c.id === "inlet" && values.structure === "집수정"; + const drawn = drawnNow(); + const form = basin + ? drawn?.basin + : c.id === "soil_add" + ? drawn?.extras[index] + : drawn?.forms[c.id]; + return form ? { value: form, tag: basin ? "자동" : "기본" } : null; + }, auto: (_c, item) => { if (item.id !== "length") return ""; const length = pipeLengthM(chainage); @@ -276,13 +325,21 @@ export function createFacilityOptionsForm( // 흙막이 개수 = 유출 「추가 기슭막이(단)」(54-1) count: (c) => (c.id === "soil_add" ? pipeExtraCount(chainage) : undefined), hint: (c, item, state) => { - const soil = c.id !== "inlet" || state.inlet?.[0]?.structure === "기슭막이"; + const soil = + c.id !== "inlet" || + (state.inlet?.[0]?.structure === "기슭막이" && !(inletIsAuto() && drawnNow()?.basin)); return item.id === "height" && soil ? guardHeightHint(state.pipe?.[0]?.diameter ?? "") : ""; }, onChange: (c, item) => { + if (c.id === "inlet" && item.id === "structure") inletAuto = null; if (c.id === "inlet" && (item.id === "structure" || item.id === "form")) inletStructureHandler?.(inletStructureOf()); emit(); + // 고른 뒤 그림이 바뀜 — 자동 표시를 새 그림으로 다시 그림(54-13) + if (c.id === "inlet") { + drawnMemo = undefined; + pipeForm.refresh(); + } }, }); // 조정창에서 옮겨 오는 행(단 수·옵션)이 붙을 자리 — 유입 · 유출 컨테이너 끝(2026-08-29 지시 4) @@ -303,6 +360,11 @@ export function createFacilityOptionsForm( const fordNeed = (): number | null => fordHeightM(fordFlow, Number(fordForm.state().ford?.[0]?.width ?? "")); const fordForm = createSpecForm(specLinks, { + shown: (c, item, values) => { + if (values[item.id]) return null; + if (c.id !== "ford") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out"); + return item.id === "diameter" ? byDefault("pipe_diameter_mm") : null; + }, hint: (c, item, state) => c.id === "ford" && item.id === "height" ? fordHeightHint(fordFlow, Number(state.ford?.[0]?.width ?? "")) @@ -368,7 +430,18 @@ export function createFacilityOptionsForm( }; // ── BOX암거 = 사용자 구조물 표 02 명세(PLAN 52-2) — 본체 · 유입 구조 · 유출 구조 ── - const boxForm = createSpecForm(specLinks, { onChange: () => emit() }); + const boxForm = createSpecForm(specLinks, { + onChange: () => emit(), + shown: (c, item, values) => { + if (values[item.id]) return null; + if (c.id !== "body") return wingDefault(c.id === "inlet" ? "wing_in" : "wing_out"); + const size = bodyOf({ + body_width_m: Number(defaults.body_width_m ?? 2), + body_height_m: Number(defaults.body_height_m ?? 2), + }).size; + return item.id === "size" && size ? { value: size, tag: "기본" } : null; + }, + }); // 날개벽 칸 — 세월교 · BOX 가 같은 조각을 쓰던 자리 · 조정 행은 명세 유입 · 유출 칸으로 옮겨 붙음 const wingInFields = createWingFields("날개벽(유입)", WING_IN_KEYS); const wingOutFields = createWingFields("날개벽(유출)", WING_OUT_KEYS); @@ -460,8 +533,10 @@ export function createFacilityOptionsForm( inletStructure: inletStructureOf, setInletStructure(value) { // 조정창 형식 → 유입 보호공 구조 · 형태(자동 = 고른 구조는 지키고 형태만 비움) - if (value === "revet") pipeForm.setValue("inlet", "structure", "기슭막이"); - else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정"); + if (value === "revet") { + inletAuto = null; + pipeForm.setValue("inlet", "structure", "기슭막이"); + } else if (value !== "auto") pipeForm.setValue("inlet", "structure", "집수정"); if (value !== "revet" && pipeForm.state().inlet?.[0]?.structure === "집수정") pipeForm.setValue("inlet", "form", value === "auto" ? "" : value); syncVisibility(); @@ -505,9 +580,16 @@ export function createFacilityOptionsForm( // 받은 값만 칸에 — 없으면 「안 정함」(새 시설은 부르는 쪽이 등록부 기본값을 실어 줌 · 46-7). const groups = Object.fromEntries((definition?.groups ?? []).map((g) => [g.id, g.name])); const names = definition?.spec_names ?? {}; + defaults = Object.fromEntries((definition?.options ?? []).map((o) => [o.key, o.default])); if (facility === "pipe") { // 정의 파일의 옛 이름 「흙막이 추가」 — 명세 이름 「흙막이」가 앞섬(54-1) const { soil_add: _old, ...pipeNames } = names; + const kind = options.inlet_structure; + inletAuto = + options.inlet_type === "기슭막이" && + !["revet", "I", "L", "U"].includes(String(kind ?? "")) + ? { structure: kind } + : null; pipeForm.write(PIPE_SPEC, options, pipeNames, groups); } if (facility === "box_culvert") { @@ -537,6 +619,11 @@ export function createFacilityOptionsForm( // 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈). if (current === "pipe") { Object.assign(options, pipeForm.read()); // 명세 컨테이너 값 = 옛 키 그대로(PLAN 52-1) + // 자동(규칙)은 고를 때까지 그대로 — 보여 주기만으로 파일이 안 바뀜(54-13) + if (inletIsAuto()) { + if (inletAuto!.structure === undefined) delete options.inlet_structure; + else options.inlet_structure = inletAuto!.structure; + } } else if (current === "box_culvert") { // 정본은 폭 · 높이 두 칸 — 규격이면 규격 수 · 사용자 입력이면 명세 숫자 칸(PLAN 52-2) Object.assign(options, boxForm.read(), bodyOptions(boxForm.state().body?.[0])); diff --git a/B05_Profile/B05_Profile_UI_Structures_Fields.ts b/B05_Profile/B05_Profile_UI_Structures_Fields.ts index 3ea90331b..8f446c04f 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Fields.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Fields.ts @@ -65,7 +65,14 @@ export function optionControl( // 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻. const tip = option.not_in_table ?? option.empty_means ?? ""; if (option.input === "select") { - const blank = option.required ? "— 선택 —" : "— 안 정함 —"; + // 빈 칸이 쓰는 정의 기본값은 빈 보기에 꼬리를 달아 보임 — 값은 안 고름(54-13) + const fallback = option.default === null || option.default === "" ? null : option.default; + const blank = + fallback !== null && fallback !== undefined + ? `${label(String(fallback))} (기본)` + : option.required + ? "— 선택 —" + : "— 안 정함 —"; // 정의에서 뺀 선택지(48)를 고른 옛 값 — 그 값만 남겨 보임(값을 잃지 않음) const kept = value && !option.choices.includes(value) ? [value] : []; const element = select([ diff --git a/resources/tester/ui_checks/54-13_그림값_표시.js b/resources/tester/ui_checks/54-13_그림값_표시.js new file mode 100644 index 000000000..07f6a2918 --- /dev/null +++ b/resources/tester/ui_checks/54-13_그림값_표시.js @@ -0,0 +1,255 @@ +/* 54-13 — 좌측 폼 고르기 칸이 그림이 쓰는 값(기본값 · 자동 규칙 결과)을 보이는가 · 보여 주기만으로 값이 안 바뀌는가 + * 시작 화면: B06 횡단설계(#/b06-section) 또는 B05 종단설계(#/b05-profile) · 검증 프로젝트(sub6_검증) · 로그인 + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m5413 = "단계";` · 결과 `sessionStorage.getItem("__5413_out_{단계}")` + * check → 목록의 배관 · 세월교 · BOX 전부를 열어 폼 글 ↔ 그림(B06 카드 SVG 글 · 벽 툴팁 · 상세 세트) 대조 · + * B05 에서 부르면 B06 에서 읽은 폼 글과 같은지 · 초안(관 지점 세션) 안 생김 + * old → 4+5 배관을 옛 「자동(규칙)」(유입 기슭막이 · 형식 없음)으로 초안에 넣고 새로 고침 + * oldpick→ (새로 고친 뒤) 폼 = 그림 · 관경만 고르면 유입 형식 안 굳음 · 흙막이 고르면 revet · 그림 벽 · + * 12+18 배관 집수정 높이 고침 = 집수정을 고른 것 + * box → 17+12 세월교(빈 칸) 대조 · 세션에서만 BOX 로 바꾸고 칸을 비워 규격 · 유입 · 유출 = 기본 · 저장 칸 없음 + * restore→ 초안 지우고 새로 고침 · 서버 관 지점 = 처음 사진 + * 쓰는 것: sessionStorage 만(서버 저장 없음) + * 기대 수치: 모든 단계 bad = [] + */ +(async () => { + const MODE = window.__m5413 || "check"; + 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 pid = localStorage.getItem("frd_current_project_id"); + const DRAFT = `aislo:draft:pipes:${pid}`; + const B06 = location.hash.includes("b06"); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const flat = (s) => String(s ?? "").replace(/\s+/g, ""); + const out = { mode: MODE, page: B06 ? "B06" : "B05", bad: [] }; + const bad = (what) => out.bad.push(what); + const done = () => sessionStorage.setItem(`__5413_out_${MODE}`, JSON.stringify(out)); + const api = async () => + (await (await fetch(`/api/projects/${pid}/drainage/pipe-points`)).json()).pipe_points; + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const rowOf = (head) => rows().find((li) => txt(li).startsWith(head)); + const draft = (at) => { + const raw = sessionStorage.getItem(DRAFT); + const list = raw ? (JSON.parse(raw).value ?? JSON.parse(raw)) : null; + return list?.find((p) => Math.abs(p.chainage_m - at) < 0.51) ?? null; + }; + const spec = () => + [...document.querySelectorAll(".b05-structure__spec")].find((s) => s.offsetParent); + /** 폼 고르기 칸 `{항목: 보이는 글}` · 꼬리 표시(data-spec-shown) 칸 목록. */ + const formOf = () => { + const fields = {}; + const tagged = []; + for (const wrap of spec()?.querySelectorAll("[data-spec-item]") ?? []) { + const sel = wrap.querySelector("select"); + if (!sel) continue; + fields[wrap.dataset.specItem] = sel.options[sel.selectedIndex]?.text ?? ""; + if (wrap.dataset.specShown) tagged.push(wrap.dataset.specItem); + } + return { fields, tagged }; + }; + const detailAt = (at) => { + const key = Object.keys(sessionStorage).find((k) => k.includes("section-detail")); + const raw = key ? JSON.parse(sessionStorage.getItem(key)) : null; + const list = (raw?.value ?? raw)?.cross_sections ?? []; + return list.reduce( + (a, b) => (Math.abs(b.chainage_m - at) < Math.abs((a?.chainage_m ?? 1e9) - at) ? b : a), + null, + ); + }; + /** 그림 — 고른 카드 SVG 글 · 벽 툴팁(역할 + 형태). */ + const drawn = () => { + const svg = document.querySelector(".b06-cross-card--selected svg"); + if (!svg) return null; + return { + labels: [...svg.querySelectorAll(".b06-chart__culvert-label")].map((t) => t.textContent), + walls: [...svg.querySelectorAll(".b06-chart__culvert-revet title")].map( + (t) => t.textContent.split(" H=")[0], + ), + }; + }; + const open = async (head) => { + const row = await until(() => rowOf(head)).then(() => rowOf(head)); + if (!row) return (bad(`${head}: 목록에 없음`), false); + row.click(); + await until(() => spec() && (!B06 || document.querySelector(".b06-cross-card--selected svg"))); + await w(1200); + return true; + }; + const fire = (sel, value) => { + sel.value = value; + sel.dispatchEvent(new Event("change", { bubbles: true })); + }; + const selectOf = (item) => spec()?.querySelector(`[data-spec-item="${item}"] select`); + const SHAPE = { I형: "I형", L형: "ㄴ형", ㄷ형: "ㄷ형" }; + /** 폼 글 ↔ 그림 — 종류마다 그림이 쓰는 값을 따로 읽어 대조. */ + const compare = (head, at, kind) => { + const { fields, tagged } = formOf(); + const pic = B06 ? drawn() : null; + const set = detailAt(at); + const got = { head, fields, tagged, pic }; + const blank = Object.entries(fields).filter( + ([k, v]) => v === "— 안 정함 —" && !/^(soil_add|chute_add)/.test(k), + ); + if (blank.length) bad(`${head}: 빈 칸 글 ${blank.map(([k]) => k)}`); + if (!pic) return got; // B05 — 글 대조는 B06 사진과 + const head0 = (v) => flat(v).replace(/\((기본|자동)\)$/, ""); + if (kind === "pipe") { + const basin = pic.labels.find((l) => l.startsWith("집수정")); + const inlet = head0(fields["inlet.structure"]); + if ((inlet === "집수정") !== !!basin) bad(`${head}: 유입 ${inlet} ↔ 그림 ${basin ?? "벽"}`); + const form = head0(fields["inlet.form"]); + if (basin && !basin.includes(SHAPE[form] ?? "?")) bad(`${head}: 모양 ${form} ↔ ${basin}`); + for (const [side, role] of [ + ["inlet", "유입"], + ["outlet", "유출"], + ]) { + const wall = pic.walls.find((t) => t.startsWith(`${role} 기슭막이`)); + if (side === "inlet" && basin) continue; + const f = head0(fields[`${side}.form`]); + if (!wall || flat(wall) !== flat(`${role} 기슭막이 ${f}`)) + bad(`${head}: ${side} 형태 ${f} ↔ ${wall}`); + } + const d = Math.round((set?.culvert?.diameter_m ?? 0) * 1000); + if (tagged.includes("pipe.diameter") && head0(fields["pipe.diameter"]) !== String(d)) + bad(`${head}: 관경 ${fields["pipe.diameter"]} ↔ ${d}`); + } + if (kind === "ford") { + const label = pic.labels.find((l) => l.includes("Ø")) ?? ""; + if (!label.includes(`Ø${head0(fields["ford.diameter"])}`)) + bad(`${head}: 관경 ${fields["ford.diameter"]} ↔ ${label}`); + for (const side of ["in", "out"]) { + const wing = set?.ford?.[`wing_${side}`]?.installed; + const text = head0(fields[`${side === "in" ? "inlet" : "outlet"}.structure`]); + if (wing !== undefined && (text === "날개벽") !== wing) + bad(`${head}: ${side} ${text} ↔ 설치 ${wing}`); + } + } + if (kind === "box") { + const label = pic.labels.find((l) => l.startsWith("BOX")) ?? ""; + if (flat(label) !== `BOX${head0(fields["body.size"])}`) + bad(`${head}: 규격 ${fields["body.size"]} ↔ ${label}`); + } + return got; + }; + const KIND = { 배관: "pipe", 세월교: "ford", BOX암거: "box" }; + + try { + if (MODE === "check") { + if (!sessionStorage.getItem("__5413_orig")) + sessionStorage.setItem("__5413_orig", JSON.stringify(await api())); + const before = sessionStorage.getItem(DRAFT); + const heads = rows() + .map(txt) + .filter((t) => /^\d+\+\d+ (배관|세월교|BOX암거)/.test(t)); + const b06 = JSON.parse(sessionStorage.getItem("__5413_b06") ?? "{}"); + out.rows = []; + for (const t of heads) { + const head = t.split(" ").slice(0, 2).join(" "); + if (!(await open(head))) continue; + const station = head.split(" ")[0].split("+").map(Number); + const got = compare(head, station[0] * 20 + station[1], KIND[t.split(" ")[1]]); + out.rows.push(`${head} ${JSON.stringify(got.fields)} | ${JSON.stringify(got.pic)}`); + if (B06) b06[t] = got.fields; + else if (b06[t] && JSON.stringify(b06[t]) !== JSON.stringify(got.fields)) + bad(`${head}: B05 ${JSON.stringify(got.fields)} ≠ B06 ${JSON.stringify(b06[t])}`); + else if (!b06[t]) bad(`${head}: B06 사진 없음 — B06 에서 check 먼저`); + } + if (B06) sessionStorage.setItem("__5413_b06", JSON.stringify(b06)); + if (sessionStorage.getItem(DRAFT) !== before) bad("열기만 했는데 관 초안이 바뀜"); + out.count = heads.length; + } else if (MODE === "old") { + const list = (await api()).map(({ lonlat, ...rest }) => rest); + const pipe = list.find((p) => Math.abs(p.chainage_m - 85.05) < 0.51); + pipe.options = { ...pipe.options, inlet_type: "기슭막이" }; + delete pipe.options.inlet_structure; + sessionStorage.setItem(DRAFT, JSON.stringify(list)); + out.note = "초안 넣음 — 새로 고친 뒤 oldpick"; + done(); + location.reload(); + return; + } else if (MODE === "oldpick") { + await open("4+5 배관"); + out.start = compare("4+5 옛 자동", 85.05, "pipe"); + if (!flat(out.start.fields["inlet.structure"]).endsWith("(자동)")) + bad(`옛 자동 유입 글 ${out.start.fields["inlet.structure"]}`); + // 관경만 고름 — 유입 형식은 그대로(자동 유지) + fire(selectOf("pipe.diameter"), "1200"); + await w(2500); + out.afterDiameter = draft(85.05)?.options; + if (out.afterDiameter?.inlet_structure !== undefined) + bad(`관경만 골랐는데 형식 ${out.afterDiameter.inlet_structure}`); + if (out.afterDiameter?.inlet_type !== "기슭막이") + bad(`유입 저장 값 바뀜 ${out.afterDiameter?.inlet_type}`); + out.stillAuto = formOf().fields["inlet.structure"]; + // 흙막이를 고름 = 형식 revet · 그림 벽 + fire(selectOf("inlet.structure"), "기슭막이"); + await w(3500); + out.afterPick = draft(85.05)?.options; + if (out.afterPick?.inlet_structure !== "revet") + bad(`흙막이 골랐는데 형식 ${out.afterPick?.inlet_structure}`); + out.picked = compare("4+5 흙막이 고름", 85.05, "pipe"); + if (out.picked.fields["inlet.structure"] !== "흙막이") + bad(`고른 글 ${out.picked.fields["inlet.structure"]}`); + // 다른 관(자동 집수정) — 집수정 높이 고침 = 집수정을 고른 것 + await open("12+18 배관"); + const shown = formOf().fields["inlet.structure"]; + out.other = shown; + if (flat(shown) === "집수정(자동)") { + const h = spec().querySelector('[data-spec-item="inlet.height"] input'); + h.value = "1.5"; + h.dispatchEvent(new Event("change", { bubbles: true })); + await w(3000); + const o = draft(258.12)?.options; + out.basinEdit = o; + if (o?.inlet_type !== "집수정" || o?.inlet_basin_height_m !== 1.5) + bad(`집수정 높이 고침 저장 ${JSON.stringify(o)}`); + if (formOf().fields["inlet.structure"] !== "집수정") + bad(`높이 고친 뒤 글 ${formOf().fields["inlet.structure"]}`); + } + } else if (MODE === "box") { + await open("17+12 세월교"); + out.ford = compare("17+12 세월교", 352.14, "ford"); + const type = document.querySelectorAll( + ".b05-structure-section .b05-structure__grid select", + )[1]; + fire(type, "box_culvert"); + await until(() => rowOf("17+12 BOX암거")); + await w(3000); + // 새 종류는 정의 기본값이 값으로 실림(46-7) — 칸을 비워 옛 빈 저장과 같게 + for (const k of ["body.size", "inlet.structure", "outlet.structure"]) { + fire(selectOf(k), ""); + await w(1500); + } + await w(2000); + out.box = compare("17+12 BOX", 352.14, "box"); + const f = out.box.fields; + if (flat(f["body.size"]) !== "2.0×2.0(기본)") bad(`BOX 규격 ${f["body.size"]}`); + for (const k of ["inlet.structure", "outlet.structure"]) + if (flat(f[k]) !== "날개벽(기본)") bad(`BOX ${k} ${f[k]}`); + const o = draft(352.14)?.options ?? {}; + if (["body_width_m", "wing_in", "wing_out"].some((k) => k in o)) + bad(`보여 주기만 했는데 BOX 저장 칸 ${JSON.stringify(o)}`); + } else if (MODE === "restore") { + sessionStorage.removeItem(DRAFT); + // 고르기 단계가 남긴 B06 유입 형식 세션 · 그걸 얹은 상세 캐시(다시 받음) + for (const key of Object.keys(sessionStorage)) + if (key.includes(`inletstruct:${pid}`) || key.includes(`section-detail:${pid}`)) + sessionStorage.removeItem(key); + const orig = JSON.parse(sessionStorage.getItem("__5413_orig") ?? "null"); + const now = await api(); + out.same = JSON.stringify(now) === JSON.stringify(orig); + if (!out.same) bad("서버 관 지점이 처음 사진과 다름"); + done(); + location.reload(); + return; + } + } catch (error) { + bad(String(error?.stack ?? error)); + } + done(); +})(); From b2dd0b7cf380ef02e7bddaade62a412ed493c197 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:04:33 +0900 Subject: [PATCH 17/99] =?UTF-8?q?chore(plan):=2054-16=20B05=20=C2=B7=20B06?= =?UTF-8?q?=20=EC=9E=90=EB=A3=8C=20=ED=95=9C=20=EA=B3=B3=20=EC=A1=B0?= =?UTF-8?q?=EC=82=AC=20=EA=B2=B0=EA=B3=BC=20=EB=B3=B4=EA=B4=80?= 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-06_54-16_자료_한곳.md | 91 +++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/raw/조사/2026-10-06_54-16_자료_한곳.md diff --git a/docs/raw/조사/2026-10-06_54-16_자료_한곳.md b/docs/raw/조사/2026-10-06_54-16_자료_한곳.md new file mode 100644 index 000000000..8a1e5415e --- /dev/null +++ b/docs/raw/조사/2026-10-06_54-16_자료_한곳.md @@ -0,0 +1,91 @@ +# 54-16 B05 · B06 자료 한 곳 확인 (조사 · 보고만) + +- 기준 커밋 `2104cc359` · 검증 프로젝트 `sub8_검증`(`c81b21f8…`) · 노선 224 +- 측정: 같은 창 안 넘김은 ORCA 탭 + `orca eval`. 도중 ORCA 탭이 통째로 새로 만들어져 세션 초안이 사라짐(앱 흠 아님) → 저장 · 새로고침 시나리오는 측정 전용 헤드리스 Chrome(같은 프로브 · 화면 안 띄움). ORCA 탭은 닫음 +- 값 되돌림: 높이 2 · 관경 1000 · 60m 계획고 865.48 · 3+0 측구 일반 으로 저장해 되돌림 확인. 단 횡단 저장 때 서버가 면적을 다시 계산해 누가토량은 −13,417.5 → −12,843.4 로 바뀜(서버 정본 재계산 결과 · 되돌릴 값 아님) + +## ① 자료마다 쥔 곳 + +세션 키 꼴 `aislo:{bucket}:{name}:{project}[:{route}]` (`A00_Common/b_page_state.ts:229-238`). 설계 가게(`design_store`)는 따로 값을 들지 않고 이 키를 바로 읽고 씀(`A00_Common/design/design_store.ts:133-153`) · 구독자 0. + +| 자료 | 기준 자리 | 같이 쥔 곳(두 벌) | 쓰는 곳 | 읽는 곳 | +|---|---|---|---|---| +| 구조물 목록 | ss `draft:structures` | 종단 다리 `ownStructures` · 횡단 패널 `structures` · 한 벌 폼 `structures` · 한 벌 종단도 `structures` · 서버 `structures.json`(보일 때마다 다시 받음) · 가게 칸 `structures`(아무도 안 씀) | `B05_Profile/B05_Profile_Api_Structures.ts:305` ← `B05_Profile_UI_Page_Structures.ts:245` · `B06_Section/B06_Section_UI_Page_Structures_Panel.ts:177` | `B05_Profile_UI_Page_Structures.ts:331` · `B06_..._Structures_Panel.ts:345` · `B06_Section_Section_Store.ts:61` | +| 구조물 옵션 | 구조물 초안 안 `options` | 명세 폼 `state`(`A00_Common/design/design_structure_form.ts:278,286`) | 폼 commit | `B05_Profile_UI_Structures_Panel_Commit.ts:94` | +| 관 옵션 | 관 초안 안 `options` | 배수유역도 `facilityStore` · 횡단 캐시 culvert/ford/box 스펙 · ss `culvertopt` · ss `revetx`/`fordadjust`/`boxadjust` | 종단 `B05_Profile_UI_Drainage_Panel.ts:612` · 횡단 `B06_..._Structures_Panel.ts:277-284` · `B06_Section_UI_Page_Span_Control.ts:112` · `B06_Section_UI_Page_Ford_Controls.ts:204,398` | `design_view_cross.ts:623`(마운트 한 번) · 저장 `B06_Section_Api_Culvert_Options.ts:64` | +| 컨테이너 값 | 관 `options` 의 `soil_add_{n}_*` | ss `extrawall`(단 수) · ss `extraspan` + 캐시 `extra_spans` · ss `revetx` · 모듈 전역 `sectionsSource`/`extraCountSource`(나중에 선 보기가 이김) | `B06_Section_UI_Page_Station_Controls.ts:324` · `B06_Section_UI_Page_Span_Control.ts:78-80` · `B06_Section_UI_Page_Pipe_Options.ts:229` | `design_structure_spec.ts:112-140` · `design_view_cross.ts:298` · `design_structure_pipe_calc.ts:32,60` | +| 잇기 | 관 · 구조물 초안 `summary_item`/`group_items` | 배수유역도 `facilityStore` · ss `revetlink`(마운트 때 한 번) | `B05_Profile_UI_Drainage_Panel.ts:599-600` · `B06_..._Structures_Panel.ts:245-259` | `B05_Profile_UI_Structures_Panel_Commit.ts:114,198` | +| 관 지점 | ss `draft:pipes` | 배수유역도 `pipeEditor` + `facilityStore` · `shared.lastPipes` · 종단 보기 `lastPipes` · 횡단 패널 `pipeFacilities` · 한 벌 폼 `pipeFacilities` · 종단 다리 `pipeStations`/`pipeMarks` · 서버 `pipe_points.json` | `B05_Profile_Api_Pipes_Draft.ts:27` ← `Drainage_Panel.ts:397` · `B06_..._Structures_Panel.ts:430` | `Drainage_Panel.ts:404` · `B06_..._Structures_Panel.ts:350` · `B06_Section_Section_Store.ts:105` | +| 종단 계획선 | ss `draft:alignment-draft` | 모듈 전역 EditState Map(`B05_Profile_UI_Profile_Edit.ts:143,188`) · 한 벌 종단도 `store`/`base` | `B05_Profile_UI_Profile_Edit.ts:198` | `:97` · `B06_Section_UI_Page_Design_Sync.ts:132` | +| 횡단 설계 patch | 메모리 캐시 Map(`B06_Section/B06_Section_Section_Store.ts:178-201` · 두 보기 공유) | ss `result:section-detail` 사본(받을 때만 씀 · 고침 안 받음) · ss `crossdesign` · ss `rockb`/`cutslope`/`berm`/`crossw`/`inletstruct`/`basinadjust`/`std-cross` · 종단 보기 `currentSectionDetail` · 횡단 보기 `sectionDetail` | `B06_Section_Cross_Design_Session.ts:55` · `design_view_cross.ts:270-296,430` | `B06_Section_Cross_Refresh.ts:253`(재계산 때만) · `B06_Section_UI_Page_Persist.ts:377` · 마운트 `design_view_cross.ts:565-567,612` | +| 사토 | 구조물 목록 안 사토장 | 캐시 `design.spoil_fill_*` | 서버 저장 때만 `B06_Section_Server_Calc_Prebuild.py:197` | `B06_Section_Cross_Refresh.ts:316-327`(저장분 폭 그대로) | +| 선택 상태 | ss `draft:structure-pick` | 가게 `selection` + ss `draft:selection`(아무도 안 씀) · 횡단 `appliedPick` | `design_selection.ts:152,172` | `design_selection.ts:112` · `B05_Profile_UI_Structure_Pick_Session.ts:34` | + +## ② 재현 (측정) + +같은 창 안 넘김 · 저장 전 + +| 자료 | B05 바꿈 → B06 | B06 바꿈 → B05 | +|---|---|---| +| 구조물 옵션(돌쌓기 높이) | 맞음 — 폼 1.5 · 카드 H=1.5m | 맞음 — 1.8 | +| 관 옵션(관경) | 맞음 — 1200 | 맞음 — 800 | +| 종단 계획선(60m ▲) | 맞음 — 865.68 | 맞음 — 865.78 | +| 선택 | 맞음 — 같은 구조물 고름 | 맞음 | +| **누가토량(같은 초안)** | **어긋남 — B05 −13,478.3 · B06 −12,888.7** | 넘긴 뒤 B05 가 B06 값(−12,931.8)으로 바뀜 | + +저장 · 새로고침 (B05: 높이 1.2 · 관경 1200 · ▲2 → B06: 측구 L형 · 관경 800 · 높이 1.0) + +| 단계 | 높이 | 관경 | 60m 계획고 | 3+0 측구 | 누가토량 | 판정 | +|---|---|---|---|---|---|---| +| 새로고침 뒤 B05 (저장 전) | 1 | 800 | 865.68 | (B05 안 보임) | **−12,915.6** (직전 −12,931.8) | **옛 값** — L형 초안이 계산에 안 얹힘 | +| 새로고침 뒤 B05 에서 [저장] → 새 창 | 1 | 800 | 865.68 | **일반** | −13,494.6 | **옛 값** — 「편집 내용을 저장했습니다」인데 L형이 안 나감 | +| 같은 창 → B06 | 1 | 800 | 865.68 | **일반**(ss 엔 L형 초안 남음) | −13,494.6 | **옛 값** — 화면은 일반 · [저장]하면 L형이 나감(화면 ≠ 저장) | +| B06 에서 [저장] → 새 창 | 1 | 800 | 865.68 | L형 | −12,931.8 | 맞음 | +| B06 관경 1000 바꾸고 바로 [저장] → 새 창 | 1 | **800** | 865.68 | L형 | −12,931.8 | **옛 값** — `pipe-points` PUT 두 번 · 둘째(배수유역도 메모리 800)가 덮음 | + +재지 않은 것(코드 읽기만): 컨테이너 값 · 잇기 · 사토 + +## ③ 원인 + +두 벌 (6) + +| # | 자리 | 왜 | +|---|---|---| +| D1 | 관 목록 세 벌 — ss 초안 · 배수유역도 메모리 · 횡단 `pipeFacilities` | [저장]이 초안을 보낸 **뒤** 배수유역도 메모리를 한 번 더 PUT(`A00_Common/design/design_shared.ts:275-281` → `B05_Profile/B05_Profile_UI_Drainage_Panel.ts:636-642`) → 늦은 메모리가 새 값을 덮음 | +| D2 | 관 옵션 네 곳 — 관 초안 · 횡단 캐시 스펙 · `culvertopt` · `revetx`/`fordadjust`/`boxadjust` | 저장 차례상 `culvertopt` 가 관 초안 뒤에 나가 이김(`design_save.ts:217-222,282-285`) | +| D3 | 흙막이 단 수 두 벌 — ss `extrawall` · 관 `soil_add_{n}_*` | 컨테이너 수는 `extrawall` 을 따름 · 모듈 전역 `sectionsSource` 는 나중에 선 보기가 이김 | +| D4 | 구조물 목록 — ss 하나 + 메모리 셋 + 보일 때마다 서버 | 숨은 쪽은 보일 때 서버 + ss 로 다시 세움(`B05_Profile_UI_Page_Structures.ts:319-322` · `B06_..._Structures_Panel.ts:334-341`) | +| D5 | 종횡단 상세 — 메모리 캐시 · ss 사본 · 종단 `currentSectionDetail` · 횡단 `sectionDetail` | ss 사본은 제자리 고침을 안 받아 늘 옛 값 · 종단 쪽은 진입 때만 받음(`design_view_profile.ts:410,442`) | +| D6 | 가게 빈 칸 셋(`structures` · `sectionDetail` · `selection`) + `draft:selection` 키 | 실제 자리는 다른 키 — 「한 곳」이 겉모양뿐 · `subscribe` 구독자 0 | + +다른 보기로 갈 때 덮음 · 안 얹음 + +- **카드 선택 초안이 재계산 길에서만 얹힘** — `crossDesignChoices` 를 읽는 곳은 `B06_Section/B06_Section_Cross_Refresh.ts:253`(계획고 재계산)과 저장(`B06_Section_UI_Page_Persist.ts:377`)뿐 → 새로고침 · 저장 뒤 카드는 서버 설계(일반)를 보이고 ss 엔 L형이 남음 (재현 2 · 3) +- **일반 [저장]은 횡단 보기가 섰을 때만 횡단 초안을 보냄** — 안 섰으면 `saveCachedCrossPatches` 가 캐시 메모리만(`B06_Section_Section_Store.ts:259`) · ss 초안(`crossdesign` · `rockb` 등) 빠짐 · 그래도 「저장했습니다」 (재현 3) +- 종단 보기 onShow 가 횡단 자료를 다시 안 읽음(`B05_Profile_UI_Page_Life.ts:30-35` · `design_view_profile.ts:548-551`) · `applyPipes(lastPipes)`(`:550`)가 끝나지 않은 메모리 목록을 폼에 실음 +- 횡단 onShow 에 관 옵션 초안 다시 얹기 없음 — 마운트 때만(`design_view_cross.ts:623`) +- 종단 [저장] 뒤 캐시를 비워도(`B05_Profile_UI_Page_Actions.ts:154`) 숨은 횡단 `sectionDetail` 은 옛 객체 → `forwardPipeOptions`(`design_view_profile.ts:299`)가 버린 객체를 고침 +- 관을 전부 지운 빈 초안 `[]` 을 「없음」으로 봐 서버 목록을 받음(`Drainage_Panel.ts:405-407`) → [저장]이 지운 관을 되살림 + +한쪽만 알림 + +- **구조물 · 관 바꿈이 횡단 면적에 다시 얹히는 길이 횡단 쪽에만** — `refreshDetailForStructures`(`design_view_cross.ts:143`) · 종단 보기에서 바꾸면 종단 누가토량이 옛 면적 (재현 1: 같은 초안 −13,478.3 대 −12,888.7) +- `toAll("onPipesChanged")`(`design_shared.ts:158-161`) — 횡단 쪽 묶음(`B06_Section_UI_Section_View_Profile.ts:179-216`)에 받는 칸 없음 → 실제로는 종단만 받음 +- 「구조물 배치」 폼 알림은 지금 보기에만(`design_shared.ts:183`) +- 관 옵션 넘김 한 방향 — 종단 → 횡단 `forwardPipeOptions` · 횡단 → 종단은 초안 + 200ms 재읽기(`design_view_cross.ts:146`)뿐 +- 숨은 종단 보기는 관 목록을 담기만(`design_view_profile.ts:153`) → 횡단에서 [저장]하면 옛 비정규 측점이 `uphill` 로 나감(`B05_Profile_UI_Page_Actions.ts:128-134`) + +## ④ 한 곳으로 모을 갈래 (8 · 작은 것부터) + +목표: 자료 하나 = 세션 키 하나(가게 칸 하나) · 두 보기는 읽기만 · 바꿈 알림 한 길 · 보기 넘길 때 서버로 덮지 않음 · [저장]은 세션 키를 그대로 보냄(5장 데이터 3층) + +| 차례 | 갈래 | 크기 | 고치는 재현 | +|---|---|---|---| +| 1 | 저장 줄기 관 한 번 — 둘째 `side.savePipes()` PUT 을 빼고 초안 하나만(시설 되붙이기는 초안 쪽에서) · `design_shared.ts:275-281` | 작음 | 관경 바로 저장 옛 값 | +| 2 | 카드 선택 초안(`crossdesign` · `rockb` · `cutslope` 등)을 상세를 **불러올 때 한 번** 캐시에 얹음(`B06_Section_Section_Store.ts` load) — 재계산 길에만 두지 않음 | 작음~중간 | 새로고침 뒤 카드 일반 · B05 누가토량 어긋남 | +| 3 | 일반 [저장]이 보기와 무관하게 횡단 ss 초안을 늘 보냄(`saveCachedCrossPatches` 가 ss 초안 포함) | 작음~중간 | B05 [저장] L형 빠짐 | +| 4 | ss `result:section-detail` 사본 — 제자리 고침을 같이 쓰거나 지움 | 작음 | 새로고침 뒤 옛 사본 | +| 5 | 구조물 · 관 바꿈 → 횡단 상세 벽 · 면적 다시 얹기를 공용 한 길로(어느 보기에서 바꿔도) · 종단 `currentSectionDetail` 대신 `Section_Store` 캐시 하나만 읽음 | 중간 | 같은 초안 누가토량 두 값 | +| 6 | 관 목록 한 벌 — 배수유역도 메모리 · 횡단 `pipeFacilities` · `shared.lastPipes` 를 ss 초안 읽기 전용으로 · `onPipesChanged` 를 두 보기 모두 · 빈 초안 `[]` 도 초안으로 | 중간 | 관 지점 두 벌 | +| 7 | 관 옵션 한 자리 — `culvertopt` · `revetx` · `extrawall` 을 관 초안 `options` 로 모음 · 흙막이 단 수 한 벌 · 저장 차례 겹침 없앰 | 큼 | 관 옵션 네 곳 | +| 8 | 가게를 실제 한 창구로 — 자료마다 키 하나 · `subscribe` 알림으로 두 보기 다시 그림 · 빈 칸 정리 · 보기 onShow 서버 재조회는 초안 없을 때만 | 큼 | 두 벌 전반 | From e0953e333e85e9fa15e9877128b5b8307b4c66c8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:12:50 +0900 Subject: [PATCH 18/99] =?UTF-8?q?fix(ui):=20=EC=A2=8C=EC=B8=A1=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=EC=95=84=EB=9E=98=20=EB=8B=A8=EC=B6=94=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=ED=95=9C=20=EC=96=91=EC=8B=9D=20=C2=B7=20[=3F]=20?= =?UTF-8?q?=EC=A0=95=EC=82=AC=EA=B0=81=20=C2=B7=20=EC=A4=84=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=EC=97=86=EC=9D=8C(54-20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공용 규칙 한 곳(overlay.css) — 단추 사이 --btn-group-gap · 고르게 늘되 글보다 좁아지지 않음 · 줄바꿈 막음 - [?] 너비 = 높이(32px) - B05·B06 전용 여백 규칙(design_dock.css) 지움 — 공용으로 옮김 - 치수 재기 ui_checks/54-20_좌측단추_치수.js - 전: 높이 31.7·45.7(B04 [이 모델 확정] · B07 [현재 도면 확정] 두 줄) · 여백 8/16 섞임 · B06 [종단 보기] 글이 단추 꽉 참 · [?] 36×31.7 - 후: B02~B09 높이 31.7 하나 · 글 14px Inter 500 하나 · 줄바꿈·넘침 0 · 좁은 패널(280px)도 같음 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- A00_Common/design/design_dock.css | 7 -- .../tester/ui_checks/54-20_좌측단추_치수.js | 83 +++++++++++++++++++ ui_template/ui_template_help.css | 7 +- ui_template/ui_template_overlay.css | 15 ++++ 4 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 resources/tester/ui_checks/54-20_좌측단추_치수.js diff --git a/A00_Common/design/design_dock.css b/A00_Common/design/design_dock.css index dcfcf207f..e69de29bb 100644 --- a/A00_Common/design/design_dock.css +++ b/A00_Common/design/design_dock.css @@ -1,7 +0,0 @@ -/* 좌측 패널 아래 고정 영역의 단추 넷([초기화][저장][확정][보기 전환]) — 좁은 패널에서도 글자가 - 두 줄로 꺾이지 않게 안쪽 여백을 줄이고 줄바꿈을 막는다(계획서 24-2b). */ -.b05-route__dock .b05-route__actions .ui-btn, -.b05-route__dock .b06-profile__actions .ui-btn { - padding-inline: var(--spacing-8); - white-space: nowrap; -} diff --git a/resources/tester/ui_checks/54-20_좌측단추_치수.js b/resources/tester/ui_checks/54-20_좌측단추_치수.js new file mode 100644 index 000000000..e99c7b4dd --- /dev/null +++ b/resources/tester/ui_checks/54-20_좌측단추_치수.js @@ -0,0 +1,83 @@ +/* 54-20 — B02~B09 좌측 패널 아래 공통 단추 묶음([?] · [초기화] · [저장] · [확정] · [횡단 보기] …) 치수 + * 시작 화면: 아무 B 화면(로그인 · 환경 배정 storage 프로젝트가 현재 프로젝트) + * 부르는 법: orca eval --expression "$(cat 이 파일)" · 1분 넘으면 끊기므로 + * `window.__btn5420 = ["b05-profile", …];` 를 앞에 붙여 나눠 부름 + * 쓰는 것: 없음(주소 이동 · 재기만 · 끝나면 처음 주소로) + * 기대 수치: + * - 줄마다 보이는 단추 h 하나 · fs 하나 · ff 하나 · 줄 안 gap 하나 · wrap = false · over = false + * - [?] = 정사각(w = h) + * - 좁은 패널(narrow = true 로 패널 폭을 최소로 줄여 다시 잼)에서도 wrap · over 없음 + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const vis = (n) => n && n.offsetParent !== null; + const start = location.hash; + const routes = window.__btn5420 ?? [ + "b02-proj-register", + "b03-file-input", + "b04-preprocess", + "b05-profile", + "b06-section", + "b07-design-detail", + "b08-quantity", + "b09-estimation", + ]; + const measure = () => { + const help = [...document.querySelectorAll(".ui-help-btn")].find(vis); + const rows = help + ? [help.closest(".ui-sidebar-actions") ?? help.parentElement] + : [...document.querySelectorAll(".ui-sidebar-actions")].filter(vis); + return rows.map((row) => { + const btns = [...row.querySelectorAll("button")].filter(vis); + const rr = row.getBoundingClientRect(); + return { + row: row.className, + rowW: Math.round(rr.width), + gap: getComputedStyle(help?.parentElement ?? row).gap, + btns: btns.map((b) => { + const s = getComputedStyle(b); + const r = b.getBoundingClientRect(); + const text = document.createRange(); + text.selectNodeContents(b); + return { + t: b.innerText.trim(), + cls: b.className, + h: +r.height.toFixed(1), + w: +r.width.toFixed(1), + fs: s.fontSize, + fw: s.fontWeight, + ff: s.fontFamily.split(",")[0], + pad: s.padding, + bd: s.borderWidth + " " + s.borderStyle, + rad: s.borderRadius, + top: +(r.top - rr.top).toFixed(1), + wrap: text.getBoundingClientRect().height > parseFloat(s.fontSize) * 1.6, + // 글이 단추 안쪽(여백 뺀 폭)을 넘거나 단추가 줄 밖으로 나감 + over: + text.getBoundingClientRect().width > + b.clientWidth - parseFloat(s.paddingLeft) - parseFloat(s.paddingRight) + 0.5 || + r.right > rr.right + 1, + }; + }), + }; + }); + }; + const out = []; + for (const route of routes) { + location.hash = `#/${route}`; + await w(4500); + const res = { page: route.slice(0, 3).toUpperCase(), rows: measure() }; + const panel = document.querySelector(".ui-workflow-overlay__panel--title"); + if (window.__btn5420narrow && panel) { + const was = panel.style.width; + panel.style.width = "var(--wf-left-panel-narrow-width)"; // 좁은 창(860px 아래) 폭 + await w(400); + res.narrowW = Math.round(panel.getBoundingClientRect().width); + res.narrow = measure(); + panel.style.width = was; + } + out.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 index 7c9f3f69e..3585bea06 100644 --- a/ui_template/ui_template_help.css +++ b/ui_template/ui_template_help.css @@ -2,9 +2,10 @@ .ui-sidebar-actions .ui-help-btn, .ui-help-btn { flex: none; - width: auto; - min-width: 2.25rem; - padding-inline: var(--spacing-12); + /* 정사각 — 너비 = 높이(글 한 줄 · 위아래 여백 · 테두리) */ + width: calc(1em + 2 * var(--spacing-8) + 2px); + min-width: 0; + padding-inline: 0; } .ui-help.ui-modal { diff --git a/ui_template/ui_template_overlay.css b/ui_template/ui_template_overlay.css index e7b8a37c0..cff809cab 100644 --- a/ui_template/ui_template_overlay.css +++ b/ui_template/ui_template_overlay.css @@ -368,6 +368,21 @@ min-width: 0; } +/* 아래 단추 묶음(54-20) — B03~B09 한 양식 · 사이 = --btn-group-gap · 고르게 늘되 글보다 좁아지지 + 않음 · 좁은 패널(280px)에서도 한 줄. 단추 줄 = [data-help-row](없으면 묶음 자신) */ +.ui-sidebar-actions:not(:has([data-help-row])), +.ui-sidebar-actions [data-help-row] { + gap: var(--btn-group-gap); +} + +.ui-sidebar-actions > .ui-btn:not(.ui-help-btn), +.ui-sidebar-actions [data-help-row] > .ui-btn:not(.ui-help-btn) { + flex: 1 1 0; + min-width: auto; + padding-inline: var(--spacing-4); + white-space: nowrap; +} + /* 사이드 패널 내부 컨테이너 공통 외곽선 — 기본 --color-border가 두 테마 모두 흐려 안 보인다는 보고(2026-08-05). 본문 보조색을 섞어 어느 테마에서든 뚜렷하게. 45%는 아직 흐리다는 재보고(2026-08-06)로 보조색 비중을 75%로 올렸다 — 테마 변수 From 976c9b28168cf6685fa8a4131465fc0508b12c52 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:13:19 +0900 Subject: [PATCH 19/99] =?UTF-8?q?chore(plan):=2010-6=20=EB=8B=B5=20?= =?UTF-8?q?=E2=80=94=20=EC=9E=90=EB=A3=8C=20=EC=9D=BC=EC=9B=90=ED=99=94=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=20=C2=B7=20=EC=86=8D=EB=8F=84=20=EB=B3=B4?= =?UTF-8?q?=EC=95=88=201=EB=8B=A8=EA=B3=84=20=EB=B0=98=EC=98=81?= 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 | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/raw/사용자요구/2026-10-05_수정요구.md b/docs/raw/사용자요구/2026-10-05_수정요구.md index 32518256a..dfcf22aab 100644 --- a/docs/raw/사용자요구/2026-10-05_수정요구.md +++ b/docs/raw/사용자요구/2026-10-05_수정요구.md @@ -181,3 +181,10 @@ B05에서 3D 영역의 상단 버튼들 중 원근으로, 직교로 버튼의 (10-6 사용자 · 추가 요청) - 페이지의 좌측 패널의 공통항목으로 들어온 버튼 세트의 버튼 크기가 조금 이상함. 점검 + +(10-6 사용자 · 답) +1. 공통 › 횡단도: 이 부분은 요청을 잊음. 생각나면 다시 말하겠음. 이슈 닫음 +2. B05와 B06은 공간이 부족하여 분리되어있는 상태임. 공통양식으로 좌측 패널을 사용하는 이유이며 어느 페이지에서 수정하던 공유가 되고 해당 값에 의해 종단과 횡단이, 페이지 제어가 되어야함. 꼼꼼히 분석 후 일원화 방향으로 제안. 단, 현재의 좌측패널로 종횡단 제어가 되어야함. 불필요한 정보라면 삭제도 가능(특히 이전 B08의 수량산출로 넘어가는 정보들은 재정의 필요) +3. 반영 +4. 일단 유지 +5. 너의 편의대로 진행 From 14a9366b30f6fb0c81a4c0b35f11819bc74c19ff Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 12:15:27 +0900 Subject: [PATCH 20/99] =?UTF-8?q?feat(structure):=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A4=84=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9E=87=EA=B8=B0=20=EB=9E=A8=ED=94=84=20=E2=80=94=20=EC=B8=A1?= =?UTF-8?q?=EC=A0=90=20=EC=99=BC=EC=AA=BD=20=EA=B3=A0=EC=A0=95=20=ED=8F=AD?= =?UTF-8?q?=20=EC=B9=B8=20=C2=B7=203=ED=96=89=20=C3=97=20=EC=97=B4=20?= =?UTF-8?q?=C2=B7=20=EC=B4=88=EB=A1=9D=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=B9=A8=EA=B0=95=20=ED=95=84=EC=88=98=20=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EB=85=B8=EB=9E=91=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=A0=95=20=C2=B7=20=ED=88=B4=ED=8C=81=20?= =?UTF-8?q?=EC=83=81=EC=9E=90=20=C2=B7=20=ED=95=AD=EB=AA=A9(54-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 램프 자리 = 폼 잇기 줄 규칙(명세 컨테이너 · 인스턴스 · 배관 흙막이 지형 단 수 · 정의 그룹 · 종류 단위 · B군 없음) - 단 수 바뀜 · 횡단 자료 도착 때 목록 다시 그림 - 지정 줄 왼쪽 선 = 안쪽 그림자(글 시작 자리 같게) - ui_checks/54-15_잇기_램프.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/design/design_view_cross.ts | 1 + B05_Profile/B05_Profile_UI_Structures_List.ts | 94 ++++++- .../B05_Profile_UI_Structures_Panel.ts | 2 +- .../B05_Profile_UI_Style_Structures.css | 30 ++- resources/tester/ui_checks/54-15_잇기_램프.js | 242 ++++++++++++++++++ 5 files changed, 365 insertions(+), 4 deletions(-) create mode 100644 resources/tester/ui_checks/54-15_잇기_램프.js diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 065766020..046fbeadf 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -576,6 +576,7 @@ export async function mountCrossView( } // 공유 캐시 — 종단 보기가 이미 받아 뒀으면 같은 객체를 즉시 재사용한다. sectionDetail = await loadSectionDetail(projectId, context.route_id); + structuresPanel.facility.recount(); // 단 수는 횡단 자료로 셈 — 흙막이 상자 · 목록 램프 다시(54-15) // 구조물 초안(저장 안 한 벽)은 저장소가 이미 얹어 준다. 면적만 여기서 다시 얹으면 // 첫 화면의 카드·유토곡선이 초안 기준으로 선다(2026-09-06 실측). if (sectionDetail) { diff --git a/B05_Profile/B05_Profile_UI_Structures_List.ts b/B05_Profile/B05_Profile_UI_Structures_List.ts index ee699ec95..9f75cc147 100644 --- a/B05_Profile/B05_Profile_UI_Structures_List.ts +++ b/B05_Profile/B05_Profile_UI_Structures_List.ts @@ -15,7 +15,17 @@ import { } from "./B05_Profile_Api_Structures"; import type { PipeFacilityItem } from "./B05_Profile_UI_Structures_Panel"; import { stationLabel as formatStation } from "../common_util/common_util_station"; -import { readSummaryItem, summaryTooltip } from "../common_util/common_util_summary_item"; +import { + readGroupItems, + readSummaryItem, + summaryLabel, + summaryTooltip, + type SummaryItem, +} from "../common_util/common_util_summary_item"; +import { STRUCTURE_SPECS } from "../A00_Common/design/design_structure_pipe"; +import { pipeExtraCount } from "../A00_Common/design/design_structure_pipe_calc"; +import { containerTitle, linkOf, readState } from "../A00_Common/design/design_structure_spec"; +import { asRoad } from "./B05_Profile_UI_Structures_Define"; export interface StructureListParams { /** 목록을 담을