diff --git a/B05_Profile/B05_Profile_Router.py b/B05_Profile/B05_Profile_Router.py index 1414aa63..ff6f6c1c 100644 --- a/B05_Profile/B05_Profile_Router.py +++ b/B05_Profile/B05_Profile_Router.py @@ -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", diff --git a/B05_Profile/B05_Profile_Router_Corridor.py b/B05_Profile/B05_Profile_Router_Corridor.py index 43ad92e1..cb4214b7 100644 --- a/B05_Profile/B05_Profile_Router_Corridor.py +++ b/B05_Profile/B05_Profile_Router_Corridor.py @@ -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: diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 405c1219..16d44fab 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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 { // 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다. 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) { diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts index c90ac42f..7540adba 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts index 53c0facb..17ec7a2b 100644 --- a/B06_Section/B06_Section_UI_Cross_Revetment.ts +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -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); + }; } diff --git a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts index e82a2a7d..02db256f 100644 --- a/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Structure_Panel.ts @@ -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 사용자 ⑤ — 상태 따라 // 나타났다 사라지면 레이아웃이 널뛴다). diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 9f778bea..23afdb3e 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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, diff --git a/B06_Section/B06_Section_UI_Standard_Panel.ts b/B06_Section/B06_Section_UI_Standard_Panel.ts index 2055c94c..1f22ce0c 100644 --- a/B06_Section/B06_Section_UI_Standard_Panel.ts +++ b/B06_Section/B06_Section_UI_Standard_Panel.ts @@ -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; diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 113289b3..798a0e6f 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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; diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index 7370cc15..28a7b9b8 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -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"],