From 1e02ecc19606efe097e99f0b105704f57a49e040 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 6 Oct 2026 10:26:34 +0900 Subject: [PATCH] =?UTF-8?q?fix(structure):=20=EA=B4=80=20=EC=A7=80?= =?UTF-8?q?=EC=A0=90=20=EC=8B=9C=EC=84=A4=20=EC=A2=85=EB=A5=98=20=EB=B0=94?= =?UTF-8?q?=EA=BE=B8=EA=B8=B0=20=EB=92=A4=20B06=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=C2=B7=20=EC=95=8C=EC=95=BD=20=C2=B7=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EB=B0=94=EB=A1=9C=20=EC=83=88=20=EC=A2=85=EB=A5=98?= =?UTF-8?q?(54-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌측 패널 종류 바꿈 = 고른 관 지점 실시간 반영 - B06 관 고침이 시설 종류를 받음 · 옛 종류 칸 안 이음 · 알약 · 횡단 다시 그림 - 횡단 상세 읽기에 초안 종류 얹기(짝 산식 common_util_culvert_sets) · 카드 제목 라벨 포함 - 상세 조회가 측점 라벨을 지금 관 지점 종류로(저장 뒤 다시 열기) - 구조물 다시 그림 최신 요청만 채택 - 시험: test_b05_pipe_station_relabel · ui_checks 54-4_종류바꾸기_횡단 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- A00_Common/design/design_view_cross.ts | 6 +- B05_Profile/B05_Profile_Engine_Sections.py | 65 +++++- .../B05_Profile_UI_Structures_Panel_Events.ts | 2 + B06_Section/B06_Section_Router.py | 2 + B06_Section/B06_Section_Section_Store.ts | 68 ++++++ .../B06_Section_UI_Page_Structures_Panel.ts | 17 +- .../tester/test_b05_pipe_station_relabel.py | 74 ++++++ .../tester/ui_checks/54-4_종류바꾸기_횡단.js | 211 ++++++++++++++++++ 8 files changed, 429 insertions(+), 16 deletions(-) create mode 100644 resources/tester/test_b05_pipe_station_relabel.py create mode 100644 resources/tester/ui_checks/54-4_종류바꾸기_횡단.js diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 3acaa9e8e..a534c03dc 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -203,11 +203,15 @@ export async function mountCrossView( view: () => sectionView, }); + let structuresRefreshSeq = 0; /** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */ async function refreshDetailForStructures(): Promise { if (!projectId || currentRouteId === null) return; + const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4) try { - sectionDetail = await loadSectionDetail(projectId, currentRouteId); + const fresh = await loadSectionDetail(projectId, currentRouteId); + if (seq !== structuresRefreshSeq) return; + sectionDetail = fresh; // 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가 // 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측). if (sectionDetail) { diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index f804f7e13..c2c8c88c8 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -8,6 +8,7 @@ longitudinal/, 각 횡단은 cross_sections/ 아래 파일로 저장한다. DB import json import logging +import re from dataclasses import replace from pathlib import Path from typing import Any @@ -33,6 +34,7 @@ from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_REVET, PipePoint, load_pipe_points_file, + parse_pipe_points, pipe_anchor_clearances, route_signature, ) @@ -205,6 +207,56 @@ def _load_pipe_points(project_root: Path, polyline: list[list[float]]) -> list[P return points +def pipe_station_label(pipe: PipePoint, types: dict[str, Any]) -> str | None: + """관 지점 측점 라벨 — 배관 = 「관종 D관경」 · 그 밖 = 종류 이름 · 물넘이포장 = 없음.""" + if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: + return None + if pipe.facility == PIPE_FACILITY_PIPE: + values = pipe.options or {} + kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) + diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM)) + return f"{kind} D{diameter}" + definition = types.get(pipe.facility) + return definition.name if definition is not None else None + + +def relabel_pipe_stations(project_root: Path, cross_sections: list[dict[str, Any]]) -> None: + """횡단 측점 라벨을 **지금 관 지점 종류**로 맞춘다(54-4). + + 라벨은 측점 생성 때 적혀 파일에 남는다 — 저장으로 종류만 바꾸면(배관 → BOX암거) 측점은 + 다시 안 만들어져 카드 제목이 옛 종류로 남는다. 관 지점 라벨(종류 이름 · 「관종 D관경」)인 + 측점만 고치고 다른 구조물 라벨은 건드리지 않는다. + """ + path = ( + project_root + / "B04_PreProcess" + / DRAINAGE_CACHE_DIRNAME + / DRAINAGE_EDITS_DIRNAME + / DRAINAGE_PIPE_POINTS_FILENAME + ) + try: + points = parse_pipe_points(json.loads(path.read_text(encoding="utf-8")).get("points")) + except (OSError, ValueError, AttributeError): + return + types = structure_type_map() + pipe_names = {definition.name for definition in types.values() if definition.managed_by} + for section in cross_sections: + label = section.get("structure") + if not isinstance(label, str) or not ( + label in pipe_names or re.fullmatch(r"\S+ D\d+", label) + ): + continue + chainage = float(section.get("chainage_m") or 0.0) + pipe = next((p for p in points if abs(p.chainage_m - chainage) < 0.02), None) + if pipe is None: + continue + fresh = pipe_station_label(pipe, types) + if fresh is None: + section.pop("structure", None) + else: + section["structure"] = fresh + + def resolve_extra_stations( project_root: Path, pipes: list[PipePoint] ) -> tuple[tuple[float, str], ...]: @@ -224,18 +276,9 @@ def resolve_extra_stations( types = structure_type_map() derived: list[tuple[float, str]] = [] for pipe in pipes: - if pipe.facility == PIPE_FACILITY_FORD_PAVEMENT: + label = pipe_station_label(pipe, types) + if label is None: continue - if pipe.facility == PIPE_FACILITY_PIPE: - values = pipe.options or {} - kind = str(values.get("pipe_kind") or PIPE_DEFAULT_TYPE) - diameter = int(float(values.get("pipe_diameter_mm") or PIPE_DEFAULT_DIAMETER_MM)) - label = f"{kind} D{diameter}" - else: - definition = types.get(pipe.facility) - if definition is None: - continue - label = definition.name # 독립 기슭막이는 구간(시작~종료)이라 **시작·기준·종료** 세 측점을 심는다 — # 길수록 횡단도가 여러 장 나온다(구 D군 구간형과 같은 규칙). 나머지 관 시설은 # 기준 한 곳(관이 노선을 가로지르는 점형). diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts index 163e45ed3..140499f9b 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel_Events.ts @@ -50,6 +50,8 @@ export function bindStructuresEvents(ctx: StructuresEventContext): void { // 수동 추가든 자동 배치 지점 편집이든 같은 옵션을 받는다(2026-08-17 사용자 지시: // 자동은 통수단면으로 자리를 잡아 준 것일 뿐 같은 구조물이다). ctx.syncFacilityForm(); + // 고른 관 지점의 시설 종류를 바꿈(54-4) — 실시간 반영으로 목록 · 알약 · 횡단도가 바로 따라옴 + if (ctx.selectedPipeChainage() !== null && ctx.currentType()?.managed_by) ctx.liveCommit(); if (ctx.editingId()) { ctx.setEditingId(null); ctx.syncButtons(); diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 526138242..5190e78c2 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -16,6 +16,7 @@ from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions, resolve_gra from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile from B05_Profile.B05_Profile_Engine_Sections import ( prune_stale_cross_files, + relabel_pipe_stations, run_section_generation, ) from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions @@ -269,6 +270,7 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. # 독립 기슭막이도 2026-08-28 이관으로 **관 숨김 세트**로 여기 함께 얹힌다(pipe_points). attach_culvert_sets(root, cross_sections) + relabel_pipe_stations(root, cross_sections) # 저장으로 종류만 바꾼 관 — 카드 제목(54-4) # 좌측 「구조물 배치」로 넣은 C군 벽(옹벽·돌쌓기 등)도 같은 제원 자리에 얹는다 — # 그래야 횡단도·설계선 트림·폐회로 면적이 그 벽을 본다(2026-09-06 사용자 확정). attach_wall_structures(root, cross_sections) diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 41ce275ee..2eb75a1db 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -22,6 +22,13 @@ import { fetchStructureTypes, readPendingStructures, } from "../B05_Profile/B05_Profile_Api_Structures"; +import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; +import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; +import { + attachCulvertSets, + buildCulvertSets, + registryDefaults, +} from "@util/common_util_culvert_sets"; import { attachWallSpecs, wallSpecsFrom, @@ -55,6 +62,7 @@ async function withDraftWalls( // ⚠ **빈 목록도 「초안 없음」이다**(2026-09-09). 예전에는 `[]` 가 「초안이 있는데 벽이 // 하나도 없다」로 읽혀 **아래에서 서버 저장분을 통째로 지웠다** — 구조물을 놓아도 // 횡단도에 아무것도 안 보이던 결함의 원인이다(창 둘에서 같은 증상, 실측 확인). + await withDraftPipeKinds(detail, projectId); if (!drafts || !drafts.length) return withStructureAreas(detail); const types = await fetchStructureTypes().catch(() => []); const names = new Map( @@ -77,6 +85,66 @@ async function withDraftWalls( return withStructureAreas(detail); } +/** 관 지점 시설 → 횡단 자리 키(파이썬 `_SECTION_KEYS` · 배관 · 독립 기슭막이 = culvert). */ +const FACILITY_KEYS = ["culvert", "ford", "box", "ford_pavement"] as const; +const facilityKey = (facility: string): (typeof FACILITY_KEYS)[number] => + facility === "ford_bridge" + ? "ford" + : facility === "box_culvert" + ? "box" + : facility === "ford_pavement" + ? "ford_pavement" + : "culvert"; + +/** + * 저장 전에 **종류를 바꾼 관**(배관 ↔ BOX암거 ↔ 세월교 ↔ 물넘이포장 · 54-4)을 상세에 얹는다. + * 서버는 저장분 종류로만 얹어 준다 — 초안 종류와 다른 관만 옛 자리를 걷고 짝 산식 + * (`common_util_culvert_sets`)으로 새 종류 제원을 얹는다. 옛 종류 칸 값은 섞지 않는다. + */ +async function withDraftPipeKinds(detail: SectionDetailResponse, projectId: string): Promise { + const pipes = readPendingPipes(projectId); + if (!pipes?.length) return; + const sections = detail.cross_sections as unknown as Array>; + const near = (spec: Record, section: Record, at: number) => + Math.abs(Number(spec.chainage_m ?? section.chainage_m) - at) < PIPE_MATCH_M; + /** 그 관 자리에 얹힌 시설 키가 이 종류와 맞는가 — 얹힌 것이 없으면(새 관 · 옮긴 관) 대상 밖. */ + const fits = (pipe: (typeof pipes)[number], key: string, spec: Record) => + key === facilityKey(pipe.facility ?? "pipe") && + (key !== "culvert" || (spec.hidden_pipe === true) === (pipe.facility === "revetment")); + const stale = pipes.filter((pipe) => { + const here = sections.flatMap((section) => + FACILITY_KEYS.flatMap((key) => { + const spec = section[key] as Record | undefined; + return spec && near(spec, section, pipe.chainage_m) ? [[key, spec] as const] : []; + }), + ); + return here.length > 0 && !here.some(([key, spec]) => fits(pipe, key, spec)); + }); + if (!stale.length) return; + const types = await fetchStructureTypes().catch(() => []); + const registry = registryDefaults(types); + for (const section of sections) { + for (const key of FACILITY_KEYS) { + const spec = section[key] as Record | undefined; + if (spec && stale.some((pipe) => near(spec, section, pipe.chainage_m))) delete section[key]; + } + // 카드 제목 구조물 이름 — 서버 `resolve_extra_stations` 라벨 규칙(배관 = 관종 D관경 · 물넘이 = 없음) + // 라벨 자리 = 이름 있는 측점 · 관 자리 그 측점(물넘이로 지웠다 되돌린 때) + const pipe = stale.find((entry) => { + const gap = Math.abs(entry.chainage_m - Number(section.chainage_m)); + return section.structure ? gap < PIPE_MATCH_M : gap < 0.01; + }); + if (!pipe) continue; + const facility = pipe.facility ?? "pipe"; + const values = { ...registry.pipe, ...pipe.options }; + if (facility === "ford_pavement") delete section.structure; + else if (facility === "pipe") + section.structure = `${values.pipe_kind} D${Math.trunc(Number(values.pipe_diameter_mm))}`; + else section.structure = types.find((type) => type.type_id === facility)?.name ?? facility; + } + attachCulvertSets(sections, buildCulvertSets(stale, registry)); +} + /** * 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다. * diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index d45b00c53..acb756d91 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -243,18 +243,27 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc } // 값은 관 목록 초안(가게 pipes) 하나로 나간다 — 조정 예약(culvertopt)에 따로 담으면 비운 칸이 // [저장] 때 옛 값으로 되살아난다. 옮기기 전 자리로 찾는다(저장 시점 목록 기준). - deps.applyPipeOptions?.(fromChainageM, patch); const hit = pipeFacilities.find( (entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M, ); + const was = hit?.facility ?? "pipe"; + const facility = attributes.facility ?? was; + // 종류를 바꿈(54-4) — 옛 종류 그림 캐시에 새 종류 칸을 얹지 않음 · 옛 칸은 안 이음 + if (facility === was) deps.applyPipeOptions?.(fromChainageM, patch); if (hit) { - const facility = hit.facility ?? "pipe"; hit.options = - mergeFacilityOptions({ facility, options: hit.options }, { facility, options: patch }) - .options ?? {}; + mergeFacilityOptions( + { facility: was, options: hit.options }, + { facility, options: patch }, + ).options ?? {}; + hit.facility = facility; } persistPipes(); section.setPipeFacilities(pipeFacilities); + if (facility !== was) { + pushMarks(); + deps.onStructuresChanged?.(); // 횡단 상세에 새 종류를 얹어 다시 그림(`withDraftPipeKinds`) + } }, onPipeRemove: (chainageM) => removePipeAt(chainageM), onPipeSelect: (chainageM) => { diff --git a/resources/tester/test_b05_pipe_station_relabel.py b/resources/tester/test_b05_pipe_station_relabel.py new file mode 100644 index 000000000..d31a94891 --- /dev/null +++ b/resources/tester/test_b05_pipe_station_relabel.py @@ -0,0 +1,74 @@ +"""54-4 — 저장으로 관 지점 종류만 바꾸면 횡단 측점 라벨(카드 제목)이 새 종류를 따라감. + +측점은 다시 안 만들어지므로 라벨은 파일에 옛 종류로 남음 — 상세 조회가 지금 관 지점으로 고침. +다른 구조물 라벨(옹벽 등)은 건드리지 않음. +""" + +import json + +from B05_Profile.B05_Profile_Engine_Sections import relabel_pipe_stations, resolve_extra_stations +from common_util.common_util_drainage_pipes import parse_pipe_points + + +def _write_points(root, points): + edits = root / "B04_PreProcess" / "drainage" / "edits" + edits.mkdir(parents=True, exist_ok=True) + (edits / "pipe_points.json").write_text(json.dumps({"points": points}), encoding="utf-8") + + +def _sections(): + return [ + {"chainage_m": 80.0}, + {"chainage_m": 85.05, "structure": "파형강관 D900"}, + {"chainage_m": 120.0, "structure": "옹벽"}, + {"chainage_m": 173.09, "structure": "세월교"}, + {"chainage_m": 258.12, "structure": "파형강관 D800"}, + ] + + +def test_relabel_follows_saved_kind(tmp_path): + _write_points( + tmp_path, + [ + {"chainage_m": 85.05, "source": "spacing", "facility": "box_culvert"}, + {"chainage_m": 120.0, "source": "user", "facility": "box_culvert"}, # 옹벽 라벨 자리 + { + "chainage_m": 173.09, + "source": "stream", + "facility": "pipe", + "options": {"pipe_kind": "흄관", "pipe_diameter_mm": 1200}, + }, + {"chainage_m": 258.12, "source": "spacing", "facility": "ford_pavement"}, + ], + ) + sections = _sections() + relabel_pipe_stations(tmp_path, sections) + labels = {s["chainage_m"]: s.get("structure") for s in sections} + assert labels == { + 80.0: None, + 85.05: "BOX암거", + 120.0: "옹벽", # 관 지점 라벨이 아닌 측점은 그대로 + 173.09: "흄관 D1200", + 258.12: None, # 물넘이포장 = 라벨 없음(측점 생성 규칙과 같음) + } + + +def test_relabel_matches_generation_rule(tmp_path): + points = [ + {"chainage_m": 85.05, "source": "spacing", "options": {"pipe_diameter_mm": 1000}}, + {"chainage_m": 173.09, "source": "stream", "facility": "ford_bridge"}, + ] + _write_points(tmp_path, points) + sections = [ + {"chainage_m": 85.05, "structure": "세월교"}, + {"chainage_m": 173.09, "structure": "BOX암거"}, + ] + relabel_pipe_stations(tmp_path, sections) + generated = dict(resolve_extra_stations(tmp_path, parse_pipe_points(points))) + assert [s["structure"] for s in sections] == [generated[85.05], generated[173.09]] + + +def test_relabel_without_file_keeps_labels(tmp_path): + sections = _sections() + relabel_pipe_stations(tmp_path, sections) + assert sections == _sections() diff --git a/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js b/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js new file mode 100644 index 000000000..92cc1c07c --- /dev/null +++ b/resources/tester/ui_checks/54-4_종류바꾸기_횡단.js @@ -0,0 +1,211 @@ +/* 54-4 — 좌측 패널에서 같은 자리 관 지점 시설 종류를 바꿀 때 B06 횡단도 · 종단 알약 · 목록이 바로 새 종류 + * 시작 화면: B06 횡단설계(#/b06-section) · 검증 프로젝트(sub4_검증) · 로그인 · 목록에 「4+5 배관 1」(85.05 m) + * 부르는 법: 단계마다 한 번씩 — 앞에 `window.__m544 = "단계";` + * setup → 처음 관 지점 정본(서버)을 세션 `__544_orig` 에 남김 · 4+5 관 고름 · 종단 도크 폄 + * cycle → 저장 전 네 종류 서로 바꾸기 12길(배관 → BOX → 세월교 → 물넘이 → 배관 → 세월교 → BOX → + * 물넘이 → 세월교 → 배관 → 물넘이 → BOX → 배관) — 길마다 횡단 카드 모양 · 제목 · 목록 · 알약 · 초안 칸 + * save → BOX암거로 바꾸고 [저장] → 저장 뒤 카드 · 목록 · 알약 = BOX + * reopen → (orca reload 뒤) 저장분이 BOX 로 다시 섬 · BOX → 배관 바꾸기도 바로 따라옴(저장 뒤 길) + * restore → 처음 정본을 관 초안에 되얹고 [저장] → 서버 관 지점 = 처음과 같음 + * 긴 단계는 `void` 로 띄우고 `sessionStorage.getItem("__544_out_{단계}")` 로 읽음 + * 쓰는 것: 검증 프로젝트 관 지점 정본(save · restore) · sessionStorage + * 기대 수치: 모든 단계 fails 0 · restore same true + */ +(async () => { + const MODE = window.__m544 || "cycle"; + const raf = window.requestAnimationFrame; + window.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 16); + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const AT = 85.05; + const pid = localStorage.getItem("frd_current_project_id"); + const api = async (u) => (await fetch(u, { credentials: "include" })).json(); + const txt = (el) => el.innerText.replace(/\s+/g, " ").trim(); + const NAME = { + pipe: "배관", + box_culvert: "BOX암거", + ford_bridge: "세월교", + ford_pavement: "물넘이포장", + }; + const CHIP = { + pipe: "배수관", + box_culvert: "BOX암거", + ford_bridge: "세월교", + ford_pavement: null, + }; + /** 그 종류에만 있는 칸 — 다른 종류로 바꾼 뒤 초안에 남으면 옛 칸이 섞인 것. */ + const OWN = { + pipe: ["inlet_revet_form", "outlet_revet_form", "inlet_basin_length_m"], // 세월교도 inlet_type 을 씀(날개벽) + box_culvert: ["body_width_m", "body_height_m"], + ford_bridge: ["pipe_count"], + ford_pavement: [], + }; + const typeSel = () => + document.querySelectorAll(".b05-structure-section .b05-structure__grid select")[1]; + const rows = () => [...document.querySelectorAll(".b05-route__irregular-list li")]; + const row = () => rows().find((li) => /^4\+5 (배관|BOX암거|세월교|물넘이포장)/.test(txt(li))); + const card = () => + [...document.querySelectorAll(".b06-cross-card")] + .map((c) => ({ c, at: Number(txt(c).split(" ")[1]?.replace("m", "")) })) + .sort((a, b) => Math.abs(a.at - AT) - Math.abs(b.at - AT))[0]?.c; + const pill = () => + [...document.querySelectorAll(".b05-structure__mark")].find( + (m) => m.dataset.structureId === `pipe-${AT.toFixed(2)}`, + ); + const draft = () => { + const raw = sessionStorage.getItem(`aislo:draft:pipes:${pid}`); + const v = raw ? JSON.parse(raw) : null; + return (v?.value ?? v ?? []).find((p) => Math.abs(p.chainage_m - AT) < 0.51) ?? null; + }; + const openDock = async () => { + if (pill()) return; + const handle = document.querySelector(".ui-workflow-overlay__handle--bottom"); + if (handle?.getAttribute("aria-expanded") !== "true") handle?.click(); + for (let k = 0; k < 20 && !pill(); k++) await w(300); + }; + const pick = async () => { + for (let k = 0; k < 40 && !row(); k++) await w(500); + if (!row()?.classList.contains("is-selected")) { + row()?.click(); + await w(2500); + } + }; + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 200) { + if (fn()) return true; + await w(200); + } + return false; + }; + /** 횡단 카드 그림이 그 종류 모양인가 — 클래스 · 툴팁(svg title) 첫 낱말로 가름. */ + const shapeOf = (c) => { + const svg = c.querySelector("svg"); + const cls = [...svg.querySelectorAll("[class]")].map((x) => x.getAttribute("class")); + const tips = [...svg.querySelectorAll("title")].map((x) => x.textContent); + if (tips.some((t) => t.startsWith("BOX암거"))) return "box_culvert"; + if (cls.some((k) => k.includes("ford-slab"))) return "ford_bridge"; + if (cls.some((k) => k.includes("culvert-revet") || k.includes("culvert-pipe"))) return "pipe"; + return "none"; + }; + const check = (to, label) => { + const c = card(); + const got = { + shape: c ? shapeOf(c) : "카드 없음", + chip: c ? [...c.querySelectorAll(".b06-cross-card__structure")].map(txt) : [], + notes: c ? [...c.querySelectorAll(".b06-cross-card__warning")].map(txt) : [], + row: row() ? txt(row()) : null, + pill: pill() ? `${txt(pill())}|${pill().title}` : null, + type: typeSel().value, + facility: draft()?.facility, + keys: Object.keys(draft()?.options ?? {}), + }; + const want = to === "ford_pavement" ? "none" : to; + if (got.shape !== want) fail(`${label}: 모양 ${got.shape} ≠ ${want}`); + if (CHIP[to] ? got.chip.join() !== CHIP[to] : got.chip.length) + fail(`${label}: 제목 ${got.chip}`); + if (to === "ford_pavement" && !got.notes.some((n) => n.includes("물넘이포장"))) + fail(`${label}: 물넘이 알림 없음 ${got.notes}`); + if (got.notes.some((n) => Object.values(NAME).some((v) => v !== NAME[to] && n.includes(v)))) + fail(`${label}: 옛 종류 알림 ${got.notes}`); + if (!got.row?.startsWith(`4+5 ${NAME[to]}`)) fail(`${label}: 목록 ${got.row}`); + if (!got.pill || !got.pill.includes(NAME[to])) fail(`${label}: 알약 ${got.pill}`); + if (got.type !== to) fail(`${label}: 폼 종류 ${got.type}`); + if (!["setup", "save", "reopen"].includes(MODE) && got.facility !== to) + fail(`${label}: 초안 종류 ${got.facility}`); + for (const [kind, keys] of Object.entries(OWN)) + if (kind !== to && keys.some((k) => got.keys.includes(k))) + fail(`${label}: 옛 ${kind} 칸 섞임 ${got.keys.filter((k) => keys.includes(k))}`); + return got; + }; + const change = async (to) => { + const t = typeSel(); + t.value = to; + t.dispatchEvent(new Event("change", { bubbles: true })); + await until(() => card() && shapeOf(card()) === (to === "ford_pavement" ? "none" : to)); + await w(600); + }; + const save = async () => { + [...document.querySelectorAll("button")].find((b) => b.innerText.trim() === "저장").click(); + await until(() => document.querySelector(".ui-toast, [class*='toast']"), 60000); + await w(4000); + return document.querySelector(".ui-toast, [class*='toast']")?.innerText ?? null; + }; + const out = { mode: MODE, fails: [] }; + const fail = (what) => out.fails.push(what); + + if (MODE === "setup") { + const pipes = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + sessionStorage.setItem("__544_orig", JSON.stringify(pipes)); + out.orig = pipes.find((p) => Math.abs(p.chainage_m - AT) < 0.51); + await pick(); + await openDock(); + out.start = check(typeSel().value, "처음"); + } else if (MODE === "cycle") { + await pick(); + await openDock(); + const path = [ + "pipe", + "box_culvert", + "ford_bridge", + "ford_pavement", + "pipe", + "ford_bridge", + "box_culvert", + "ford_pavement", + "ford_bridge", + "pipe", + "ford_pavement", + "box_culvert", + "pipe", + ]; + if (typeSel().value !== path[0]) fail(`처음 종류 ${typeSel().value}`); + out.steps = []; + for (let i = 1; i < path.length; i++) { + const label = `${i} ${path[i - 1]}→${path[i]}`; + await change(path[i]); + const got = check(path[i], label); + out.steps.push( + `${label} ${got.shape} ${got.chip.join("/") || "-"} | ${got.row} | ${got.pill}`, + ); + } + out.ways = new Set(path.slice(1).map((to, i) => `${path[i]}→${to}`)).size; + } else if (MODE === "save") { + await pick(); + await openDock(); + await change("box_culvert"); + out.toast = await save(); + await openDock(); + out.after = check("box_culvert", "저장 뒤"); + const saved = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + out.server = saved.find((p) => Math.abs(p.chainage_m - AT) < 0.51)?.facility; + if (out.server !== "box_culvert") fail(`서버 종류 ${out.server}`); + } else if (MODE === "reopen") { + await pick(); + await openDock(); + out.reopened = check("box_culvert", "다시 연 BOX"); + await change("pipe"); + out.toPipe = check("pipe", "저장 뒤 BOX→배관"); + } else if (MODE === "restore") { + const orig = JSON.parse(sessionStorage.getItem("__544_orig") ?? "null"); + if (!orig) fail("처음 정본 없음 — setup 먼저"); + else { + const points = orig.map(({ lonlat, ...rest }) => rest); // 좌표는 서버가 다시 계산(DetailPipeInput) + sessionStorage.setItem(`aislo:draft:pipes:${pid}`, JSON.stringify(points)); + location.reload(); + return "reload — 다시 부르면 저장"; // 새로 고친 뒤 `__m544 = "restore2"` + } + } else if (MODE === "restore2") { + await pick(); + out.toast = await save(); + const orig = JSON.parse(sessionStorage.getItem("__544_orig")); + const now = (await api(`/api/projects/${pid}/drainage/pipe-points`)).pipe_points; + const pick2 = (list) => + list.map((p) => [p.chainage_m, p.facility ?? "pipe", JSON.stringify(p.options ?? {})]); + out.same = JSON.stringify(pick2(now)) === JSON.stringify(pick2(orig)); + if (!out.same) fail("되돌린 정본이 처음과 다름"); + out.at = now.find((p) => Math.abs(p.chainage_m - AT) < 0.51); + } + window.requestAnimationFrame = raf; + out.failCount = out.fails.length; + sessionStorage.setItem(`__544_out_${MODE}`, JSON.stringify(out)); + return JSON.stringify(out); +})();