diff --git a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts index 170b9cbc..e19c7d66 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Alignment.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Alignment.ts @@ -502,10 +502,18 @@ export function shiftSegment( const baseZ = base.basePvi.map((node) => node.elevation_m); const current = buildAlignment(base, edits); const offsets = { ...edits.station_offsets }; + // 구간 끝은 항상 변화점이다 — 초기 오프셋은 **변화점 실제 z** 기준으로 잡는다. + // 곡선 샘플 계획고(= PVI z − 중앙종거)를 쓰면 첫 이동에서 변화점이 중앙종거만큼 + // 내려앉아 ⬆ 뒤 ⬇ 복귀가 원위치보다 낮아진다(2026-08-23 화면 실측: 복귀가 + // 최소고 가드에 걸림). 변화점이 못 찾아지는 예외에서만 샘플 보간으로 폴백한다. + const nodeElevationAt = (chainage: number): number => { + const node = current.pvi.find((entry) => Math.abs(entry.chainage_m - chainage) < 1e-6); + return node ? node.elevation_m : planElevationAt(current, chainage); + }; [segment.from_m, segment.to_m].forEach((chainage) => { const key = chainageKey(chainage); const existing = - offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage); + offsets[key] ?? nodeElevationAt(chainage) - interpolate(baseS, baseZ, chainage); offsets[key] = Number((existing + delta).toFixed(6)); }); return { ...edits, station_offsets: offsets }; diff --git a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts index 1eba6dd9..c76ed544 100644 --- a/B05_Profile/B05_Profile_UI_Profile_MinCover.ts +++ b/B05_Profile/B05_Profile_UI_Profile_MinCover.ts @@ -52,7 +52,7 @@ function numberOf(value: unknown, fallback: number): number { /** * 관 지점 목록에서 최소 여유가 필요한 측점을 뽑는다. - * 배수관·BOX암거만 대상(세월교·물넘이포장 제외). + * 물넘이포장만 제외 — 도로 위에 그대로 앉는 시설이라 요구 여유가 없다. */ export function minCoverPoints(pipes: MinCoverPipe[]): MinCoverPoint[] { const result: MinCoverPoint[] = []; diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel.ts b/B05_Profile/B05_Profile_UI_Profile_Panel.ts index fd5e28f4..6985a5ff 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel.ts @@ -500,6 +500,7 @@ export function createRouteProfilePanel( base: () => base, stationInterval: () => stationInterval, irregularStations: () => irregularStations, + minCoverTargets: () => minCoverTargets, structures: () => structures, structureTypes: () => structureTypes, selectedStationId: () => selectedStationId, diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 2c6fc399..9b14028b 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -14,11 +14,11 @@ import { longitudinalMinimumWidth, } from "../B06_Section/B06_Section_UI_Longitudinal"; import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; -import { MIN_PIPE_COVER_M } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data"; import { adjustStation, + buildAlignment, planElevationAt, setCurveRadius, shiftSegment, @@ -27,6 +27,7 @@ import { type ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit"; +import { findMinCoverViolations, type MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover"; import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul"; import { createProfileTable } from "./B05_Profile_UI_Profile_Table"; import { @@ -39,11 +40,7 @@ import { STATION_SPACING_PX, TABLE_ROW_COUNT, } from "./B05_Profile_UI_Profile_Layout"; -import { - irregularStationId, - isPipeStation, - type IrregularStation, -} from "./B05_Profile_UI_IrregularStations"; +import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations"; import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures"; import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks"; import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures"; @@ -68,6 +65,8 @@ export interface ProfileRenderContext { base: () => AlignmentBase | null; stationInterval: () => number | undefined; irregularStations: () => IrregularStation[]; + /** 횡단배수 최소 계획고 대상(시설·제원 반영) — 편집 차단 가드가 쓴다. */ + minCoverTargets: () => MinCoverPoint[]; structures: () => StructureInstance[]; structureTypes: () => StructureType[]; selectedStationId: () => string | null; @@ -270,9 +269,13 @@ export function renderProfile(ctx: ProfileRenderContext): void { // 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙). if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight)); if (alignment) { - // 배수관 측점 계획고 하향 차단(2026-08-19): 관은 지반선상 매설이라(교본 8장) - // 계획고가 지반고 + 관경 + 최소 토피 아래로 내려가면 관이 노면을 뚫는다. - // 내림(▼)만 막고 올림(▲)·다른 측점은 그대로 둔다 — 판단은 사용자 몫, 한계만 지킨다. + // 횡단배수 최소 계획고 가드(2026-08-23 개편): 편집 **후보**로 정렬을 미리 계산해 + // 시설별 최소고(배수관 관경+토피 · BOX암거 구체높이+토피 · 세월교 +물넘이 몫, + // 산식은 minCoverPoints 동일 원천) 위반이 **새로 생기거나 커지면** 차단한다. + // 측점 ▼뿐 아니라 구간 ⇧⇩, 이웃 틸트가 종단곡선(중앙종거)을 거쳐 배관 계획고를 + // 내리는 경로까지 같은 가드로 잡는다 — 기존 관경 고정 산식은 구간 쉬프트를 아예 + // 안 막았고 BOX암거·세월교를 과소 차단했다(2026-08-23 사용자 보고). + // 이미 위반이면 악화만 막는다 — 복구 편집(올림)은 항상 허용돼야 한다. const groundAt = (chainageM: number): number | null => { if (!base || !base.chainage.length) return null; const { chainage: xs, ground: ys } = base; @@ -286,19 +289,26 @@ export function renderProfile(ctx: ProfileRenderContext): void { } return ys[ys.length - 1]; }; - const blocksPipeLowering = (chainageM: number, delta: number): boolean => { - if (delta >= 0) return false; - const pipe = irregularStations.find( - (entry) => isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < 0.02, + const blocksMinCover = (next: AlignmentEdits): boolean => { + if (!base) return false; + const targets = ctx.minCoverTargets(); + if (!targets.length) return false; + const candidate = buildAlignment(base, next); + const planned = findMinCoverViolations(targets, groundAt, (chainageM) => + planElevationAt(candidate, chainageM), ); - if (!pipe) return false; - const ground = groundAt(chainageM); - if (ground === null) return false; - const diameterM = (pipe.diameterMm ?? 1000) / 1000; - const minPlan = ground + diameterM + MIN_PIPE_COVER_M; - if (planElevationAt(alignment, chainageM) + delta >= minPlan - 1e-6) return false; + if (!planned.length) return false; + const current = new Map( + findMinCoverViolations(targets, groundAt, (chainageM) => + planElevationAt(alignment, chainageM), + ).map((violation) => [violation.chainage_m, violation.shortfall_m]), + ); + const worsened = planned.find( + (violation) => violation.shortfall_m > (current.get(violation.chainage_m) ?? 0) + 1e-6, + ); + if (!worsened) return false; showToast( - `배수관 측점 — 관 매설 최소 높이(지반 + 관경 ${diameterM.toFixed(1)}m + 토피 ${MIN_PIPE_COVER_M.toFixed(1)}m)에 도달해 더 내릴 수 없습니다.`, + `${worsened.label} — 최소 계획고(지반 +${worsened.clearance_m.toFixed(1)}m) 아래로 내려갈 수 없습니다.`, "warning", ); return true; @@ -315,11 +325,17 @@ export function renderProfile(ctx: ProfileRenderContext): void { entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6, ), onStation: (chainage, delta) => { - if (!base || blocksPipeLowering(chainage, delta)) return; - ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)); + if (!base) return; + const next = adjustStation(base, store.edits(), chainage, delta); + if (blocksMinCover(next)) return; + ctx.applyEdits(next); + }, + onSegment: (segment, delta) => { + if (!base) return; + const next = shiftSegment(base, store.edits(), segment, delta); + if (blocksMinCover(next)) return; + ctx.applyEdits(next); }, - onSegment: (segment, delta) => - base && ctx.applyEdits(shiftSegment(base, store.edits(), segment, delta)), onResetStation: (chainage) => store.resetStation(chainage), // 구간 원복 = 양 끝 측점 오프셋 삭제(측점 원복 연산 ×2). onResetSegment: (segment) => {