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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 사용자 ⑤ — 상태 따라
|
||||
// 나타났다 사라지면 레이아웃이 널뛴다).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user