From 819aa643dc80141442de05eea6b0fd6442e8242b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 16:19:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8):=20=EC=A2=8C=EC=B8=A1?= =?UTF-8?q?=20[=EC=86=8C=EB=8B=A8]=20=ED=8C=A8=EB=84=90=20=E2=80=94=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=EC=97=90=20=EB=86=93=EA=B3=A0=20=EB=B9=BC?= =?UTF-8?q?=EA=B8=B0=20+=20=ED=99=95=EC=A0=95=20=EB=92=A4=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EB=82=A8=EA=B2=8C=20=ED=95=A8=20(=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=9C=203-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자가 소단을 **직접 놓는** 화면을 만들었음. 프로그램이 「붕괴 우려 지역」을 판정하지 않고, 법정 기준 셋 중 무엇도 자동 적용하지 않음(2026-09-07 사용자 확정). 폼은 C군 구간형 구조물과 **같은 꼴** — 기준 측점 + 전·후 거리로 종단 범위를 잡고 폭·간격·기울기를 함께 받음. 기본값 폭 0.5m · 간격(사면길이) 3.0m · 안쪽 2°. 새 모듈 `B06_Section_UI_Berm_Panel.ts` 로 뺐음 — 좌측 페이지가 이미 700줄을 넘어 있어 거기 더 넣지 않고 두 줄만 꽂음(그 파일 분리는 별건). 세션에는 **구간 목록**으로 둠. 측점별로 펴서 저장하면 「어디부터 어디까지 놓았나」를 되짚을 수 없음. 읽는 자리에서 펴고, 서버에는 측점키 dict 로 실어 보냄. **확정 뒤에도 계단이 남게** — 소단 제원을 설계 결과에 되싣고(`berm`), `USER_TOUCHED_KEYS` 에 넣어 재계산이 지우지 않게 함. 서버 재계산은 세션값이 없으면 저장분을 씀(`stored_berm`). ⚠ 소단은 사용자 조작값이면서 **기하 입력**이라 다른 사용자 키와 다름 — 계산 뒤에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아 서로 어긋남. 그래서 단측점 설계 경로는 저장분을 **계산 전에** 읽도록 순서를 바꿨고, 포장 강제·세월교 노면 하강 재계산 경로에도 저장분 소단을 실었음. `extra_spans` 가 그렇게 빠져 있던 자리와 같음. 자체검증 — 새 시험 2건(저장분만으로 같은 설계가 나오는지 · 값이 없거나 손상되면 None). 전체 541 passed · 18 skipped. TS 타입 검사·ruff 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_Cross_Refresh.ts | 29 +++- B06_Section/B06_Section_Engine_Design.py | 8 + B06_Section/B06_Section_Router.py | 34 ++-- B06_Section/B06_Section_Router_Design.py | 21 ++- B06_Section/B06_Section_UI_Berm_Panel.ts | 185 +++++++++++++++++++++ B06_Section/B06_Section_UI_Page.ts | 19 ++- B06_Section/B06_Section_UI_Page_Persist.ts | 41 ++++- 7 files changed, 303 insertions(+), 34 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Berm_Panel.ts diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index 602f9b3c..6509d231 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,7 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { readBermSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; import { crossDesignChoices } from "./B06_Section_Cross_Design_Session"; import { @@ -81,6 +81,8 @@ export const USER_TOUCHED_KEYS = [ "extra_spans", "revet_link_detached", "revet_follow_grade", + // 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", ] as const; /** 다시 계산해도 살려 두는 값 — 위 사용자 값에 **상태를 나르는 둘**을 더한 것. */ @@ -133,6 +135,25 @@ function readRockOffsets(projectId: string, routeId: number): Map { + const spans = readBermSpans(projectId, routeId); + const out: Record = {}; + if (!spans.length) return out; + for (const section of detail.cross_sections) { + const spec = bermSpecAt(spans, section.chainage_m); + if (spec) out[rockKey(section.chainage_m).toFixed(2)] = spec; + } + return out; +} + /** * 브라우저 안에서 전 측점을 다시 계산한다(정상 경로). * @@ -165,9 +186,9 @@ function refreshLocally(input: CrossRefreshInput): number[] | null { const rockDefault = readRockBoundaryDefault(projectId); // 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는 // 순간 계단이 사라진다(계획서 3-9). - const berms = readBermSession(projectId, input.routeId); + const bermSpans = readBermSpans(projectId, input.routeId); const bermAt = (chainageM: number): BermSpec | null => { - const spec = berms[rockKey(chainageM).toFixed(2)]; + const spec = bermSpecAt(bermSpans, chainageM); return spec ? { widthM: spec.width_m, intervalM: spec.interval_m, slopeDeg: spec.slope_deg } : null; @@ -254,7 +275,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { { fullDesigns: true, rockBoundaryOffsets: readRockBoundarySession(projectId, routeId), - berms: readBermSession(projectId, routeId), + berms: bermPayload(projectId, routeId, detail), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Engine_Design.py b/B06_Section/B06_Section_Engine_Design.py index 8a0fcf11..367b2bec 100644 --- a/B06_Section/B06_Section_Engine_Design.py +++ b/B06_Section/B06_Section_Engine_Design.py @@ -780,6 +780,14 @@ def compute_cross_design( if drop > 0: # 내려 앉힌 양 — 프론트가 "월류가 없었다면" 노면을 점선으로 되그리는 데 쓴다. result["surface_drop_m"] = round(drop, 4) + if berm is not None: + # 소단 제원을 설계에 되싣는다 — 세션이 비어도(확정 뒤·다른 PC) 저장분만으로 + # 계단이 다시 서야 한다. 암 경계선 오프셋을 echo 하는 것과 같은 까닭이다. + result["berm"] = { + "width_m": round(float(berm.width_m), 4), + "interval_m": round(float(berm.interval_m), 4), + "slope_deg": round(float(berm.slope_deg), 4), + } if paved: result["pavement_thickness_m"] = round(paved_group["pavement_thickness_m"], 4) # 암 지반은 경계선 오프셋을 echo해 프론트가 세션값 없이도 오버레이·재계산에 쓰게 한다. diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index ff330c5b..006ad692 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -43,6 +43,7 @@ from B06_Section.B06_Section_Router_Design import ( USER_TOUCHED_KEYS, ford_drop_at, ford_surface_drops, + stored_berm, ) from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, @@ -607,6 +608,16 @@ async def compute_cross_section_design( content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, ) stored_path = await get_project_storage_relative_path(connection, project_id) + # 저장분을 **계산 전에** 읽는다 — 소단은 사용자 조작값이면서 **기하 입력**이라, + # 나중에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아 + # 서로 어긋난다(2026-09-07). 값을 나르는 다른 사용자 키와 다른 점이다. + stored_designs = await get_cross_section_designs(connection, route_id) + stored_design: dict[str, Any] | None = None + for record in stored_designs: + if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01: + candidate = record.get("design") + stored_design = candidate if isinstance(candidate, dict) else None + break project_root = Path(resolve_stored_project_path(stored_path)) samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread( _read_cross_design_inputs, @@ -627,27 +638,22 @@ async def compute_cross_section_design( two_stage_slope=request.two_stage_slope, ditch_enabled=request.ditch_enabled, surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)), + berm=stored_berm(stored_design or {}), **curve_widening_args(cross_record), ) # B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다. design["status"] = "provisional" # 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다. design["pavement_suggested"] = pavement_suggested + # 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이 + # 지우지 않게 한다(2026-08-06). 목록을 여기 다시 적지 않는다 — 서버의 한 벌은 + # `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07). 예전에는 여기에 + # 따로 적어 두어 `extra_spans` 가 빠져 있었다. + if stored_design is not None: + for key in USER_TOUCHED_KEYS: + if stored_design.get(key) is not None: + design[key] = stored_design[key] async with pool.acquire() as connection: - # 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이 - # 지우지 않게 한다(2026-08-06). - stored_designs = await get_cross_section_designs(connection, route_id) - for record in stored_designs: - if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01: - stored_design = record.get("design") - if isinstance(stored_design, dict): - # 목록을 여기 다시 적지 않는다 — 서버의 한 벌은 - # `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07). - # 예전에는 여기에 따로 적어 두어 `extra_spans` 가 빠져 있었다. - for key in USER_TOUCHED_KEYS: - if stored_design.get(key) is not None: - design[key] = stored_design[key] - break await connection.begin() try: updated = await update_cross_section_design( diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 6cd07943..f94d15e0 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -138,6 +138,8 @@ USER_TOUCHED_KEYS = ( "extra_spans", "revet_link_detached", "revet_follow_grade", + # 소단 제원 — 사용자가 구간에 놓은 값이라 다시 계산해도 살려 둔다(계획서 3-9). + "berm", ) @@ -193,6 +195,7 @@ def enforce_pavement_ranges( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), + berm=stored_berm(design), **curve_widening_args(section), ) except (ValueError, KeyError): @@ -244,6 +247,7 @@ def enforce_ford_surface_drops( two_stage_slope=bool(design.get("two_stage_slope", True)), ditch_enabled=design.get("ditch_enabled"), surface_drop_m=wanted, + berm=stored_berm(design), **curve_widening_args(section), ) except (ValueError, KeyError): @@ -342,6 +346,21 @@ def attach_default_designs( continue +def stored_berm(stored: dict[str, Any]) -> BermSpec | None: + """저장분에 남은 소단 제원 — 세션값이 없을 때 쓴다(확정 뒤·다른 PC).""" + spec = stored.get("berm") + if not isinstance(spec, dict): + return None + try: + return BermSpec( + width_m=float(spec.get("width_m", BERM_DEFAULT_WIDTH_M)), + interval_m=float(spec.get("interval_m", BERM_DEFAULT_INTERVAL_M)), + slope_deg=float(spec.get("slope_deg", BERM_DEFAULT_SLOPE_DEG)), + ) + except (TypeError, ValueError): + return None + + def recompute_designs_for_alignment( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], @@ -398,7 +417,7 @@ def recompute_designs_for_alignment( two_stage_slope=bool(stored.get("two_stage_slope", True)), ditch_enabled=stored.get("ditch_enabled"), surface_drop_m=ford_drop_at(chainage, ford_drops), - berm=session_berms.get(key), + berm=session_berms.get(key) or stored_berm(stored), **curve_widening_args(section), ) except (ValueError, KeyError): diff --git a/B06_Section/B06_Section_UI_Berm_Panel.ts b/B06_Section/B06_Section_UI_Berm_Panel.ts new file mode 100644 index 00000000..4ee00280 --- /dev/null +++ b/B06_Section/B06_Section_UI_Berm_Panel.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * B06_Section_UI_Berm_Panel.ts + * 좌측 [소단] 패널 — 사용자가 **구간에 소단을 놓고 빼는** 자리 (계획서 3-9). + * + * 왜 자동이 아닌가 (2026-09-07 사용자 확정) — 소단 규격이 법령·도로·사방 기준마다 갈려 + * (별표2 사면길이 2~3m마다 폭 50~100㎝ / KDS 높이 5m마다 폭 1m / 사방 절토고 3~5m마다 + * 폭 0.5m 이상) 어느 것을 자동 적용해도 다른 설계가 된다. 그래서 **프로그램이 판정하지 + * 않고 사용자가 놓는다**. 「붕괴 우려 지역」 판정도 하지 않는다. + * + * 입력 꼴은 **C군 구간형 구조물과 같다** — 기준 측점 + 전·후 거리로 종단 범위를 잡고, + * 제원(폭·간격·기울기)을 함께 받는다. 값은 세션 초안(`berm`)에 쌓이고 [저장]·[확정]에서 + * 정본으로 나간다(CLAUDE.md 5장 데이터 3층). + * ========================================================================== */ + +import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; +import { + BERM_DEFAULT_INTERVAL_M, + BERM_DEFAULT_SLOPE_DEG, + BERM_DEFAULT_WIDTH_M, +} from "@util/common_util_cross_berm"; +import { stationFields } from "../B05_Profile/B05_Profile_UI_Structures_Fields"; +import { formatStation } from "../B05_Profile/B05_Profile_Util_Station"; +import { buildGroup } from "./B06_Section_UI_Page_Common"; +import { readBermSpans, writeBermSpans, type BermSpan } from "./B06_Section_UI_Page_Persist"; + +/** 기준측점 앞뒤 기본 거리(m) — C군 구간형 폼과 같은 값(길이 10m). */ +const DEFAULT_BEFORE_M = 5; +const DEFAULT_AFTER_M = 5; + +export interface BermPanelDeps { + /** 측점간격(m) — 측점 칸이 「3+15」 꼴을 읽고 쓰는 데 쓴다. */ + getInterval: () => number; + /** 지금 대상 — 없으면 패널은 그려지되 저장하지 않는다. */ + target: () => { projectId: string; routeId: number } | null; + /** 목록이 바뀌었을 때 — 전 측점 재계산을 부르는 자리. */ + onChange: () => void; +} + +export interface BermPanel { + root: HTMLElement; + /** 프로젝트·노선이 정해진 뒤 세션값을 다시 읽어 목록을 그린다. */ + reload: () => void; +} + +function numberField( + label: string, + value: number, + step: string, +): ReturnType { + const field = createInputField({ label, type: "number" }); + field.input.step = step; + field.input.min = "0"; + field.input.value = String(value); + return field; +} + +export function createBermPanel(deps: BermPanelDeps): BermPanel { + const root = buildGroup("소단"); + + const anchor = stationFields("기준 측점", () => deps.getInterval()); + const beforeField = numberField("기준측점 전 (m)", DEFAULT_BEFORE_M, "0.5"); + const afterField = numberField("기준측점 후 (m)", DEFAULT_AFTER_M, "0.5"); + const widthField = numberField("폭 (m)", BERM_DEFAULT_WIDTH_M, "0.1"); + const intervalField = numberField("간격(사면길이) (m)", BERM_DEFAULT_INTERVAL_M, "0.5"); + const slopeField = numberField("안쪽 기울기 (°)", BERM_DEFAULT_SLOPE_DEG, "0.5"); + + const spanRow = document.createElement("div"); + spanRow.className = "b05-structure__grid"; + spanRow.append(beforeField.root, afterField.root); + + const specRow = document.createElement("div"); + specRow.className = "b05-structure__grid"; + specRow.append(widthField.root, intervalField.root); + + const slopeRow = document.createElement("div"); + slopeRow.className = "b05-structure__grid"; + slopeRow.append(slopeField.root); + + const list = document.createElement("ul"); + list.className = "b05-route__irregular-list"; + + let selected = -1; + + const addButton = createButton({ label: "추가", variant: "filled", onClick: () => add() }); + const removeButton = createButton({ label: "삭제", variant: "ghost", onClick: () => remove() }); + removeButton.classList.add("is-danger"); + removeButton.disabled = true; + const actions = document.createElement("div"); + actions.className = "b06-profile__field-row"; + actions.append(addButton, removeButton); + + root.append(anchor.wrap, spanRow, specRow, slopeRow, actions, list); + + function spans(): BermSpan[] { + const target = deps.target(); + return target ? readBermSpans(target.projectId, target.routeId) : []; + } + + function save(next: BermSpan[]): void { + const target = deps.target(); + if (!target) return; + writeBermSpans(target.projectId, target.routeId, next); + render(); + deps.onChange(); + } + + function readNumber(field: { input: HTMLInputElement }, fallback: number): number { + const parsed = Number(field.input.value); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback; + } + + function add(): void { + const interval = deps.getInterval(); + const chainage = anchor.read(interval, true); + if (chainage === null) { + showToast("소단을 놓을 기준 측점을 넣어 주세요.", "error"); + return; + } + const width = readNumber(widthField, BERM_DEFAULT_WIDTH_M); + const gap = readNumber(intervalField, BERM_DEFAULT_INTERVAL_M); + if (width <= 0 || gap <= 0) { + showToast("소단 폭과 간격은 0보다 커야 합니다.", "error"); + return; + } + const before = readNumber(beforeField, DEFAULT_BEFORE_M); + const after = readNumber(afterField, DEFAULT_AFTER_M); + save([ + ...spans(), + { + start_m: Math.max(chainage - before, 0), + end_m: chainage + after, + width_m: width, + interval_m: gap, + slope_deg: readNumber(slopeField, BERM_DEFAULT_SLOPE_DEG), + }, + ]); + selected = -1; + } + + function remove(): void { + if (selected < 0) return; + const next = spans().filter((_span, index) => index !== selected); + selected = -1; + save(next); + } + + function render(): void { + const interval = deps.getInterval(); + const current = spans(); + list.replaceChildren(); + removeButton.disabled = selected < 0 || selected >= current.length; + if (!current.length) { + const empty = document.createElement("li"); + empty.className = "b05-route__irregular-empty"; + empty.textContent = "놓은 소단이 없습니다."; + list.append(empty); + return; + } + current.forEach((span, index) => { + const item = document.createElement("li"); + item.className = "b05-route__irregular-item"; + item.classList.toggle("is-selected", index === selected); + const where = document.createElement("strong"); + where.textContent = `${formatStation(span.start_m, interval)}~${formatStation(span.end_m, interval)}`; + const what = document.createElement("span"); + what.textContent = `폭 ${span.width_m}m · ${span.interval_m}m마다`; + item.append(where, what); + item.addEventListener("click", () => { + selected = index === selected ? -1 : index; + render(); + }); + list.append(item); + }); + } + + render(); + + return { + root, + reload(): void { + selected = -1; + render(); + }, + }; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 3f2f28b7..f3b540eb 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -4,10 +4,7 @@ import { leaveForDashboard } from "../A00_Common/b_missing_data_guard"; import { stateKey } from "../A00_Common/b_page_state"; import { navigateTo } from "../A00_Common/router"; import { createButton, createInputField, showToast } from "@ui/ui_template_elements"; -import type { - StructureInstance, - StructureType, -} from "../B05_Profile/B05_Profile_Api_Structures"; +import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; @@ -26,6 +23,7 @@ import { } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh"; +import { createBermPanel } from "./B06_Section_UI_Berm_Panel"; import { confirmCurrentSections, createRockBoundaryStore, @@ -144,8 +142,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { // 「구조물 배치」 — B05와 같은 컨테이너·하단 목록 템플릿(2026-08-29 일원화). // 하단 고정 dock 도 B05와 같은 구조: [구조물 목록][구분선][액션 버튼 행]. let structureMarksSink: - | ((structures: StructureInstance[], types: StructureType[]) => void) - | null = null; + ((structures: StructureInstance[], types: StructureType[]) => void) | null = null; const structuresPanel = createB06StructuresPanel({ projectId, // 횡단도·3D 넘김값에서 고른 것이 폼에 실릴 때 좌측 패널을 펼친다(2026-09-04 사용자). @@ -209,7 +206,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { const leftForm = document.createElement("div"); leftForm.className = "b06-profile__form"; // 순서: 구조물 배치(최상단 — 2026-08-29 사용자 지시) → 횡단 보기 설정 → 표준. - leftForm.append(structuresPanel.root, viewGroup, standardGroup, actionDock); + // 소단은 사용자가 구간에 놓는다(2026-09-07 확정) — 폼은 전용 모듈에 있다(700줄 제한). + const bermPanel = createBermPanel({ + getInterval: () => stationInterval ?? 20, + target: () => + projectId && currentRouteId !== null ? { projectId, routeId: currentRouteId } : null, + onChange: () => void reconcileStaleDesigns({ force: true }), + }); + leftForm.append(structuresPanel.root, viewGroup, bermPanel.root, standardGroup, actionDock); // 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님. attachCollapsible(leftForm); @@ -719,6 +723,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (storedOptions?.station_interval_m && storedOptions.station_interval_m > 0) stationInterval = storedOptions.station_interval_m; renderSectionDetail(); + bermPanel.reload(); // 노선이 정해진 뒤라야 세션에서 소단 목록을 읽을 수 있다. void reconcileStaleDesigns(); // 옛 암 2단계 + 종단 변경 반영 자동 재계산(E-1 + N-6) updateActionState(); } catch (error) { diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 0642671d..028af1b0 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -21,7 +21,7 @@ import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch"; import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches"; -import { readState } from "../A00_Common/b_page_state"; +import { readState, writeState } from "../A00_Common/b_page_state"; import { applyStructureAreaRows, STRUCTURE_AREA_KEYS, @@ -60,17 +60,42 @@ export interface BermSessionSpec { } /** - * 세션에 쌓인 소단 제원(측점키 → 제원). 없거나 손상되면 빈 객체. + * 사용자가 놓은 소단 **한 구간** — 종단 범위 + 제원. + * + * 사용자는 측점 하나가 아니라 **구간**에 놓는다(2026-09-07 확정: 「길이 + 기준측점 전·후」). + * 그래서 세션에는 구간 목록으로 두고, 측점별 제원은 읽는 자리에서 편다 — 구간을 측점으로 + * 펴서 저장하면 나중에 「어디부터 어디까지 놓았나」를 되짚을 수 없다. + */ +export interface BermSpan extends BermSessionSpec { + start_m: number; + end_m: number; +} + +/** + * 세션에 쌓인 소단 구간 목록. 없거나 손상되면 빈 목록. * * 암 경계선과 같은 성격이다 — 확정 전에는 세션에만 있으므로 계획선 재계산에 **함께 실어 * 보내야** 한다. 안 실으면 계획선을 고치는 순간 계단이 사라진다(계획서 3-9). */ -export function readBermSession( - projectId: string, - routeId: number, -): Record { - const stored = readState>("berm", projectId, routeId); - return stored && typeof stored === "object" ? stored : {}; +export function readBermSpans(projectId: string, routeId: number): BermSpan[] { + const stored = readState("berm", projectId, routeId); + return Array.isArray(stored) ? stored : []; +} + +export function writeBermSpans(projectId: string, routeId: number, spans: BermSpan[]): void { + writeState("berm", spans, projectId, routeId); +} + +/** 그 측점을 덮는 소단 제원 — 없으면 null. 겹치면 먼저 놓은 것이 이긴다. */ +export function bermSpecAt(spans: BermSpan[], chainageM: number): BermSessionSpec | null { + const found = spans.find( + (span) => + chainageM >= Math.min(span.start_m, span.end_m) - 1e-6 && + chainageM <= Math.max(span.start_m, span.end_m) + 1e-6, + ); + return found + ? { width_m: found.width_m, interval_m: found.interval_m, slope_deg: found.slope_deg } + : null; } /** 암 경계선 오프셋 저장소 — 값(Map)과 조정창 제어기를 함께 낸다. */