feat(B06): 독립 기슭막이 조정창 연결 + 초기화 세션·코리도 정리

백로그 3건(2026-08-28).

① 기슭막이 조정창 — 배관 세트와 같은 4축
   - RevetKey 에 "own" 추가. 조작값은 배관 벽과 같은 저장소를 쓴다
     (design.revet_adjust["own"] + 세션 b06:revetx:{project}:{route}).
   - 벽을 누르면 조정창이 열린다(활성 표시 포함). 좌우 ◀▶ · 사면 ▲▼ · 높이 ± ·
     자동 자리 초기화만 보이고 집수정·단 수·구간·연동·재질·등간격 행은 숨는다
     (재질은 구조물 옵션 형태가, 단 수는 단 수(다단)가 정본).
   - computeRevetmentLayout 이 x(좌우)·d(사면, +는 아래)·h(높이)를 반영하고
     3D도 같은 값을 읽는다(코리도 해시에 조작값 포함).

② 초기화가 프로젝트 단위 세션 값도 비운다 — b05:uphill / b06:std-cross.
   키에 route_id 가 없어 새 노선에 그대로 되붙던 값이다. 표준횡단 세션 정리는
   clearStandardCrossSession() 한 곳에서 한다.

③ 초기화가 주인 없는 코리도 파일을 지운다 — prune_corridor_files(비치명,
   삭제 개수 로그). 실측 배경: 유효 route 1개인데 19개(약 85MB)가 남아 있었다.

검증: pytest 245 passed / 7 skipped(코리도 정리 4건 신규). tsc·ruff·prettier 통과.
실측 — 315m 벽 클릭 시 조정창 제목 "기슭막이(독립)", 좌 1회·사면 아래 1회·
높이 +0.1 조작에 벽 좌표가 각각 이동하고 세션에
{"315.00:own":{"x":1,"d":1,"h":1.1}} 저장. 임시 구조물은 삭제하고 정본 복구.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-28 15:41:24 +09:00
co-authored by Claude Opus 5
parent c6b7708984
commit 0050adf7d2
11 changed files with 155 additions and 21 deletions
+20
View File
@@ -617,6 +617,7 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
"""
from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
from B05_Profile.B05_Profile_Router_Corridor import prune_corridor_files
from common_util.common_util_surface_confirmation import surface_confirmation_defaults
pool = get_db_pool()
@@ -650,6 +651,25 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
status_code=500,
content={"status": "error", "message": "초기 경로 재계산에 실패했습니다."},
)
# 옛 경로의 코리도 파일은 주인이 사라졌다 — 함께 지운다(2026-08-28 백로그).
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
if stored_path:
removed = await asyncio.to_thread(
prune_corridor_files,
Path(resolve_stored_project_path(stored_path)),
{int(latest["id"])},
)
if removed:
logger.info(
"B05 초기화: 주인 없는 코리도 파일 %d개 삭제 (project_id=%s)",
removed,
project_id,
)
except Exception: # noqa: BLE001 — 정리 실패가 초기화를 막지는 않는다
logger.exception("B05 초기화: 코리도 파일 정리 실패 (project_id=%s)", project_id)
return JSONResponse(
content={
"status": "success",
@@ -32,6 +32,28 @@ def _corridor_path(project_root: Path, route_id: int) -> Path:
return project_root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json"
def prune_corridor_files(project_root: Path, keep_route_ids: set[int]) -> int:
"""남길 경로 id 외의 코리도 파일을 지운다(지운 개수 반환).
초기화는 routes 행을 지우므로 옛 코리도 파일이 주인 없이 남는다 — 실측으로 한 프로젝트에
19개(약 85MB)가 쌓여 있었다(2026-08-28). 파일 하나 실패는 넘어간다(정리는 부가 작업).
"""
directory = project_root / "B05_Profile" / "corridor"
if not directory.is_dir():
return 0
keep = {f"corridor_{route_id:04d}.json" for route_id in keep_route_ids}
removed = 0
for path in directory.glob("corridor_*.json"):
if path.name in keep:
continue
try:
path.unlink()
removed += 1
except OSError as exc:
logger.warning("B05 코리도 정리 실패: %s (%s)", path.name, exc)
return removed
async def _resolve_project_root(project_id: UUID) -> Path | None:
pool = get_db_pool()
async with pool.acquire() as connection:
@@ -276,7 +276,8 @@ export function buildCorridorStructures(
const layout = culvertLayoutOf(section);
const fordLayout = fordLayoutOf(section);
// 독립 기슭막이 — 횡단 카드와 **같은 폴리곤**을 그대로 스윕한다(2026-08-28 사용자).
const revetLayout = computeRevetmentLayout(section);
// 조정창 조작값도 같이 읽는다 — 3D는 정본(design.revet_adjust)만 본다.
const revetLayout = computeRevetmentLayout(section, section.design?.revet_adjust?.own);
if (!layout && !fordLayout && !section.box && !revetLayout) continue;
const chainage = section.chainage_m;
const stationFrame: StructureFrame = {
@@ -601,6 +602,8 @@ export function structureHashParts(section: CrossSection): Array<string | number
ownRevet.tiers ?? "",
ownRevet.lift_m ?? "",
ownRevet.shift_m ?? "",
// 조정창 조작값이 바뀌면 저장 코리도를 만료시킨다.
JSON.stringify(section.design?.revet_adjust?.own ?? ""),
);
}
const box = section.box;
+6
View File
@@ -48,6 +48,7 @@ import {
loadSectionDetail,
saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store";
import { clearStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel";
import { migrateLegacyStations } from "./B05_Profile_Api_Structures";
import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
import "./B05_Profile_UI_Style.css";
@@ -679,6 +680,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
clearRouteLatestCache(activeProjectId);
invalidateSectionDetail(activeProjectId);
// 프로젝트 단위 세션 값도 함께 버린다 — 키에 route_id가 없어 새 노선에 그대로
// 되붙는다(2026-08-28). 초기화는 "사용자 편집을 전부 버린다"가 규약이다.
uphillOverrides.clear();
persistUphillOverrides();
clearStandardCrossSession(activeProjectId);
showToast(L("B05_Route_Reset_Success"), "success");
navigateTo(ROUTES.B05_PROFILE);
} catch (error) {
+3 -1
View File
@@ -53,7 +53,9 @@ function polygon(
}
/** 기슭막이 선택 키 — 측점 안에서 어느 벽인지 가린다. 추가 벽은 `extra{n}`. */
export type RevetKey = "inlet" | "outlet" | `extra${number}` | `bextra${number}`;
// "own" = 배관과 무관한 **독립 기슭막이**(구조물 정본 D군). 조정창·조작값 저장은
// 배관 벽과 같은 체계를 그대로 쓴다(2026-08-28 사용자: 배관측점처럼 이동).
export type RevetKey = "inlet" | "outlet" | "own" | `extra${number}` | `bextra${number}`;
/** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */
export type RevetHighlightSetter = (key: RevetKey | null) => void;
+41 -9
View File
@@ -40,6 +40,13 @@ export interface RevetmentSpec {
shift_m?: number | null;
}
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
export interface RevetmentAdjust {
x?: number;
d?: number | null;
h?: number | null;
}
export interface RevetPoint {
offset: number;
elevation: number;
@@ -57,6 +64,8 @@ export interface RevetmentTier {
export interface RevetmentLayout {
side: "left" | "right";
/** 실제로 쓴 벽 높이(m) — 조정창 높이 표시·조작의 기준값. */
heightM: number;
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 세는 데 쓴다(재렌더 중복 방지). */
chainageM: number;
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`tiers.length`)와 다르면 자리가 부족한 것이다. */
@@ -163,6 +172,7 @@ function anchorAt(
toe: RevetPoint,
liftM: number,
shiftM: number,
nudgeM: number,
): RevetPoint {
let anchor = toe;
const edge = section.design?.road_edges?.[side];
@@ -175,10 +185,11 @@ function anchorAt(
anchor = { offset: toe.offset + run * t, elevation: toe.elevation + rise * t };
}
}
if (!shiftM) return anchor;
// 좌우는 수평 이동이라 표고는 그 자리 원지반을 따른다(벽 밑이 뜨지 않게).
const outward = side === "left" ? 1 : -1;
const offset = anchor.offset + outward * shiftM;
const lateral = outward * shiftM + nudgeM;
if (!lateral) return anchor;
// 좌우는 수평 이동이라 표고는 그 자리 원지반을 따른다(벽 밑이 뜨지 않게).
const offset = anchor.offset + lateral;
const ground = groundElevationAt(section, offset);
return { offset, elevation: ground ?? anchor.elevation };
}
@@ -193,9 +204,13 @@ function anchorAt(
* 알리고, 사용자가 기준을 위로 올린 뒤(`lift_m`) 다시 늘린다.
* · 단 사이 성토사면은 정확히 1:1.2.
*/
export function computeRevetmentLayout(section: CrossSection): RevetmentLayout | null {
export function computeRevetmentLayout(
section: CrossSection,
adjust?: RevetmentAdjust | null,
): RevetmentLayout | null {
const spec = section.revetment;
const height = Number(spec?.height_m);
// 높이는 조정창 값이 있으면 그것이 우선한다(배관 벽과 같은 규칙).
const height = Number(adjust?.h ?? spec?.height_m);
if (!spec || !Number.isFinite(height) || height <= 0) return null;
const side: "left" | "right" = spec.side === "우" ? "right" : "left";
const toe = fillToeAt(section, side);
@@ -203,7 +218,10 @@ export function computeRevetmentLayout(section: CrossSection): RevetmentLayout |
const outward = side === "left" ? 1 : -1;
const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1));
const anchor = anchorAt(section, side, toe, Number(spec.lift_m) || 0, Number(spec.shift_m) || 0);
// 조정창 ▲▼(d)는 **사면 아래가 +** — 기준 올림과 반대 방향이다.
const liftM = (Number(spec.lift_m) || 0) - (adjust?.d ?? 0);
// 조정창 ◀▶(x)는 편거리 증가(+ = 화면 왼쪽)로 들어온다.
const anchor = anchorAt(section, side, toe, liftM, Number(spec.shift_m) || 0, adjust?.x ?? 0);
const tiers: RevetmentTier[] = tierAnchors(anchor, toe, height, requestedTiers).map((top) => {
const bottomElevation = top.elevation - height - REVET_EMBED_DEPTH_M;
const frontTop = top.offset + outward * REVET_THICKNESS_M;
@@ -222,6 +240,7 @@ export function computeRevetmentLayout(section: CrossSection): RevetmentLayout |
return {
side,
heightM: height,
chainageM: Number(section.chainage_m) || 0,
requestedTiers,
top: tiers[0]?.top ?? anchor,
@@ -261,9 +280,11 @@ export function appendRevetmentOverlay(
layout: RevetmentLayout | null,
x: (offset: number) => number,
y: (elevation: number) => number,
): boolean {
if (!layout || !layout.tiers.length) return false;
onSelect?: () => void,
): ((active: boolean) => void) | null {
if (!layout || !layout.tiers.length) return null;
noticeShortfall(layout);
const drawn: SVGPolygonElement[] = [];
for (const tier of layout.tiers) {
const polygon = document.createElementNS(SVG_NS, "polygon");
polygon.setAttribute(
@@ -271,7 +292,18 @@ export function appendRevetmentOverlay(
tier.polygon.map((point) => `${x(point.offset)},${y(point.elevation)}`).join(" "),
);
polygon.setAttribute("class", "b06-chart__revetment");
if (onSelect) {
// 배관 기슭막이와 같은 조작 — 벽을 누르면 조정창이 열린다(2026-08-28 사용자).
polygon.classList.add("is-selectable");
polygon.addEventListener("click", (event) => {
event.stopPropagation();
onSelect();
});
}
svg.append(polygon);
drawn.push(polygon);
}
return true;
return (active: boolean): void => {
for (const polygon of drawn) polygon.classList.toggle("is-active", active);
};
}
@@ -467,17 +467,21 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
if (!key) return;
document.addEventListener("keydown", onArrowKey);
const isExtra = key.startsWith("extra");
// 독립 기슭막이(구조물 정본 D군) — 배관 부속이 아니라 자리·높이만 만진다.
const isOwn = key === "own";
const movable = deps.canNudge(key);
const structure = deps.structureFor();
const isBasin =
key === "inlet" && (structure === "I" || structure === "L" || structure === "U");
title.textContent = isExtra
? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}`
: key === "outlet"
? L("B06_Cross_Revet_Outlet")
: movable
? L("B06_Cross_Revet_Inlet")
: L("B06_Cross_Struct_InletBasin");
title.textContent = isOwn
? L("B06_Cross_Revet_Own")
: isExtra
? `${L("B06_Cross_Revet_Extra")} ${Number(key.slice(5)) + 1}`
: key === "outlet"
? L("B06_Cross_Revet_Outlet")
: movable
? L("B06_Cross_Revet_Inlet")
: L("B06_Cross_Struct_InletBasin");
// 형식 선택은 유입측에서만, 이동·높이 조작은 기슭막이(집수정 제외)만.
structureRow.classList.toggle("is-hidden", key !== "inlet");
if (key === "inlet") {
@@ -494,6 +498,10 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
select.value = structure;
}
moveRow.row.classList.toggle("is-hidden", !movable);
// 등간격(≡)은 배관 유출 다단 전용이다 — 독립 기슭막이는 단 수·자리가 구조물 정본 몫.
moveRow.controls
.querySelector(".b06-structure-panel__btn--equal")
?.classList.toggle("is-hidden", isOwn);
basinRow.row.classList.toggle("is-hidden", !isBasin);
basinReset.classList.toggle("is-hidden", !isBasin);
const basinExtra = deps.basinExtraState();
@@ -510,11 +518,12 @@ export function buildStructurePanel(deps: StructurePanelDeps): StructurePanelHan
.querySelector(`.b06-structure-panel__btn--${slot}`)
?.classList.toggle("is-hidden", isI);
}
materialRow.classList.toggle("is-hidden", !movable);
// 재질은 배관 벽만 조정창에서 고른다 — 독립 기슭막이는 구조물 옵션(형태)이 정본이다.
materialRow.classList.toggle("is-hidden", !movable || isOwn);
heightRow.classList.toggle("is-hidden", !movable);
if (movable) {
heightValue.textContent = `${deps.heightFor(key).toFixed(1)}m`;
materialSelect.value = deps.materialFor(key);
if (!isOwn) materialSelect.value = deps.materialFor(key);
}
// 단 수 행은 유출 벽에서 **항상** 보인다(2026-08-22 사용자 ⑤ — 상태 따라
// 나타났다 사라지면 레이아웃이 널뛴다).
+21 -1
View File
@@ -443,7 +443,27 @@ export function createCrossSectionCard(
);
if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
// 독립 기슭막이 — 성토면 끝에 서는 벽. 3D도 같은 폴리곤을 스윕한다.
appendRevetmentOverlay(plotLayer, computeRevetmentLayout(section), x, toDisplayY);
// 조작값(4축)은 배관 벽과 같은 저장소(design.revet_adjust["own"])를 쓴다.
const ownAdjust = revetOffset?.adjustFor(section, "own");
const ownLayout = computeRevetmentLayout(section, ownAdjust);
const setOwnActive = appendRevetmentOverlay(
plotLayer,
ownLayout,
x,
toDisplayY,
revetOffset ? () => toggleRevet("own") : undefined,
);
if (ownLayout && setOwnActive) {
// 재질은 구조물 옵션(형태)이 정본이라 조정창에서 숨긴다 — 자리만 채운다.
culvertWallSpecs.set("own", { height: ownLayout.heightM, material: "dry" });
culvertAppliedD.set("own", ownAdjust?.d ?? 0);
const baseSetActive = setRevetActive;
setRevetActive = (key) => {
baseSetActive(key);
setOwnActive(key === "own");
};
if (activeRevet === "own") setOwnActive(true);
}
appendCrossDesignOverlay(
plotLayer,
section.design,
@@ -38,6 +38,15 @@ function sessionKey(projectId: string): string {
return `${SESSION_PREFIX}${projectId}`;
}
/** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */
export function clearStandardCrossSession(projectId: string): void {
try {
window.sessionStorage.removeItem(sessionKey(projectId));
} catch {
/* 세션 접근이 막혀도 초기화는 계속한다. */
}
}
/** config 기본값을 깊은 복사해 편집용 초기 상태로 만든다. */
function cloneDefaults(defaults: StandardCrossSection): StandardCrossSection {
return JSON.parse(JSON.stringify(defaults)) as StandardCrossSection;
@@ -172,6 +172,15 @@
stroke-linejoin: round;
}
.b06-chart__revetment.is-selectable {
cursor: pointer;
}
.b06-chart__revetment.is-active {
fill: color-mix(in srgb, #9b6bdc 42%, transparent);
stroke-width: 2.2;
}
/* 물넘이포장(2026-08-28): 파인 노면 — 기존 계획고 점선 + 바닥 실선 + 진한 회색 빗금 포장 */
.b06-chart__ford-deck-plan {
fill: none;
+2
View File
@@ -249,6 +249,8 @@ export const ui_locales_b2 = {
B06_Cross_Revet_Reset: ["기슭막이 자동 자리로 초기화", "Reset revetment to solved position"],
B06_Cross_Revet_Inlet: ["유입 기슭막이", "Inlet revetment"],
B06_Cross_Revet_Outlet: ["유출 기슭막이", "Outlet revetment"],
/* 배관과 무관한 독립 기슭막이(구조물 정본 D군) — 2026-08-28. */
B06_Cross_Revet_Own: ["기슭막이(독립)", "Revetment (standalone)"],
B06_Cross_Struct_Label: ["구조물 형식", "Structure type"],
B06_Cross_Revet_Extra: ["추가 기슭막이", "Extra revetment"],
B06_Cross_Revet_Up: ["사면 위로(대각) 1m", "Up along fill slope 1m"],