From eb716ffbb11492a6dfddfd1f9c5fde7664c1bc42 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 16:27:41 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EC=A2=85=EB=8B=A8=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EC=B5=9C=EC=86=8C=EA=B3=A0=20=EA=B0=80=EB=93=9C=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8=20-=20=EA=B5=AC=EA=B0=84=20=EC=89=AC?= =?UTF-8?q?=ED=94=84=ED=8A=B8=20=EC=B0=A8=EB=8B=A8=20+=20=EC=8B=9C?= =?UTF-8?q?=EC=84=A4=EB=B3=84=20=EC=82=B0=EC=8B=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-23 사용자 보고: 횡단배수 최소고를 어기고 구간 쉬프트가 되는 경우 있음(가드가 측점 ▼에만, 관경 고정 산식). - 가드 개편(blocksMinCover): 편집 후보로 정렬을 계산해 시설별 최소고 (minCoverPoints 원천 - 배수관 관경+토피, BOX암거 구체높이+토피, 세월교 +물넘이, 물넘이포장 제외) 위반이 새로 생기거나 커지면 차단. 측점 ▲▼와 구간 ⇧⇩ 모두 적용, 이웃 틸트가 종단곡선을 거쳐 배관 계획고를 내리는 경로까지 커버. 이미 위반이면 악화만 차단(복구 편집 허용). 기존 blocksPipeLowering(측점 ▼ 전용·관경 1000 고정)은 제거. - shiftSegment 시딩 수정: 초기 오프셋을 곡선 샘플 계획고(PVI z - 중앙종거) 대신 변화점 실제 z로 계산 - 첫 이동에서 변화점이 내려앉아 상승 후 하강 복귀가 비대칭이던 화면 실측 버그 해소. - MinCover 낡은 주석 정정(구현은 세월교 포함, 물넘이포장만 제외). 검증: tsc 통과 · pytest 182 passed(가드·시딩 신규 8건) · 공용 브라우저 실조작 - 배수관 앵커 구간 ⬇ 즉시 차단(+1.5), BOX암거 구간 차단(+2.5), ⬆/⬇ 대칭 복귀, 검증 상태 전부 원복. Co-Authored-By: Claude Fable 5 --- .../B05_Profile_UI_Profile_Alignment.ts | 10 ++- .../B05_Profile_UI_Profile_MinCover.ts | 2 +- B05_Profile/B05_Profile_UI_Profile_Panel.ts | 1 + B05_Profile/B05_Profile_UI_Profile_Render.ts | 64 ++++++++++++------- 4 files changed, 51 insertions(+), 26 deletions(-) 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) => {