Merge remote-tracking branch 'origin/sub_laptop_4' into sub_desktop_1

This commit is contained in:
2026-10-06 10:28:17 +09:00
8 changed files with 429 additions and 16 deletions
+5 -1
View File
@@ -203,11 +203,15 @@ export async function mountCrossView(
view: () => sectionView,
});
let structuresRefreshSeq = 0;
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
async function refreshDetailForStructures(): Promise<void> {
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) {
+54 -11
View File
@@ -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군 구간형과 같은 규칙). 나머지 관 시설은
# 기준 한 곳(관이 노선을 가로지르는 점형).
@@ -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();
+2
View File
@@ -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)
+68
View File
@@ -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<void> {
const pipes = readPendingPipes(projectId);
if (!pipes?.length) return;
const sections = detail.cross_sections as unknown as Array<Record<string, unknown>>;
const near = (spec: Record<string, unknown>, section: Record<string, unknown>, 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<string, unknown>) =>
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<string, unknown> | 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<string, unknown> | 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));
}
/**
* 구조물이 선 측점의 절·성토 면적을 **읽어 오는 자리에서 한 번** 다시 얹는다.
*
@@ -262,18 +262,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) => {
@@ -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()
@@ -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);
})();