From 0050adf7d2ca87efa598145bf27b7a16d9951df9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 28 Aug 2026 15:41:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(B06):=20=EB=8F=85=EB=A6=BD=20=EA=B8=B0?= =?UTF-8?q?=EC=8A=AD=EB=A7=89=EC=9D=B4=20=EC=A1=B0=EC=A0=95=EC=B0=BD=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20+=20=EC=B4=88=EA=B8=B0=ED=99=94=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=C2=B7=EC=BD=94=EB=A6=AC=EB=8F=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 백로그 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) --- B05_Profile/B05_Profile_Router.py | 20 ++++++++ B05_Profile/B05_Profile_Router_Corridor.py | 22 ++++++++ .../B05_Profile_UI_Corridor_Structures.ts | 5 +- B05_Profile/B05_Profile_UI_Page.ts | 6 +++ B06_Section/B06_Section_UI_Cross_Culvert.ts | 4 +- B06_Section/B06_Section_UI_Cross_Revetment.ts | 50 +++++++++++++++---- .../B06_Section_UI_Cross_Structure_Panel.ts | 27 ++++++---- B06_Section/B06_Section_UI_Cross_View.ts | 22 +++++++- B06_Section/B06_Section_UI_Standard_Panel.ts | 9 ++++ .../B06_Section_UI_Style_Cross_Areas.css | 9 ++++ ui_template/ui_template_locale_b2.ts | 2 + 11 files changed, 155 insertions(+), 21 deletions(-) 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"],