From eb41202bf40b6328b9bc23737b9f884ae7bfbf8a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 23 Aug 2026 18:54:38 +0900 Subject: [PATCH] =?UTF-8?q?fix(B06):=20=EA=B5=90=EC=B0=A8=EC=A0=90=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=A4=8C=EC=95=84=EC=9B=83=EC=9D=B4=205m?= =?UTF-8?q?=20=EC=82=AC=EB=A9=B4=20=EA=B7=9C=EC=B9=99=EC=97=90=20=EC=9E=98?= =?UTF-8?q?=EB=A6=AC=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 자동 줌아웃 판정이 배수관용 slopeToeOffset(성토사면 5m에서 컷오프)을 써서, 사면이 5m를 넘는 측점은 필요 폭을 과소 산정했고 교차점이 화면 밖에 남았다(2026-08-23 사용자 보고). - _UI_Cross_Fit 재작성: 설계선(design_line)과 지반선의 **실제 교차점**을 0.1m 간격으로 훑어 찾는다(노면·노견·측구 구간은 건너뜀). 설계선이 만나기 전에 끝나면 끝단 기울기 차로 외삽하고 1m 올림. - 카드 자동 줌아웃(effectiveHalfWidth)이 이 값을 쓴다. 보유 샘플 밖으로는 넓히지 않는다(지반이 없어 빈 화면이 될 뿐). - [적용]은 전 측점 중 필요한 최대 폭까지 함께 넓힌다 — 20m를 넘으면 그 값으로 재생성해 각 카드가 자동 줌아웃할 지반을 확보한다. 자체검증: node tmp/tests/test_toe_extrapolate.mjs (사면 18m 교차점을 5m 컷오프 없이 탐색, 설계선이 15m에서 끊겨도 외삽으로 같은 자리), tsc --noEmit 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B06_Section/B06_Section_UI_Cross_Fit.ts | 105 +++++++++++++++++------ B06_Section/B06_Section_UI_Cross_View.ts | 18 +++- B06_Section/B06_Section_UI_Page.ts | 6 +- 3 files changed, 100 insertions(+), 29 deletions(-) diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index 9751ed74..3814d143 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -3,21 +3,23 @@ * 횡단 카드 「교차점 맞춤」 — 절토·성토 사면이 원지반과 만나는 지점(교차점)이 모두 * 보이는 표시 반폭(m)을 구한다. * - * 보유 샘플(계산 반폭, 기본 20m) 안에서 교차점을 찾으면 그 자리 + 여유로 끝난다. - * 샘플 끝까지 만나지 않으면 **추세로 외삽**한다 — 끝단의 계획 사면 기울기와 지반 - * 기울기 차이로 만나는 자리를 추정하고 +1m 해서 넘긴다(2026-08-23 사용자 지시). - * 그 값이 계산 반폭을 넘으면 페이지가 백엔드 재생성으로 샘플을 넓힌다. + * 판정은 **설계선(design_line)과 지반선의 실제 교차점**으로 한다. 배수관 세트가 쓰는 + * `slopeToeOffset`은 「성토사면 5m」 규정에서 잘라 돌려주므로(기슭막이 자리 계산용) + * 사면이 5m를 넘는 측점에서 필요 폭을 과소 산정했다 — 그 값으로 자동 줌아웃을 걸면 + * 교차점이 여전히 화면 밖에 남는다(2026-08-23 사용자 보고). + * + * 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면 + * 기울기와 지반 기울기 차이로 만나는 자리를 추정하고 1m 올림한다. 그 값이 계산 반폭을 + * 넘으면 페이지가 백엔드 재생성으로 샘플을 넓힌다. * ========================================================================== */ -import type { CrossSection } from "./B06_Section_Api_Fetch"; -import { - designInterpolator, - groundInterpolator, - slopeToeOffset, -} from "./B06_Section_UI_Cross_Culvert_Solve"; +import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; +import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve"; -/** 교차점이 샘플 끝에 걸렸다고 볼 여유(m) — 끝 격자에서 잡히면 실제로는 더 밖이다. */ -const EDGE_TOLERANCE_M = 0.05; +/** 설계선과 지반선이 만났다고 보는 표고 차(m). */ +const MEET_TOLERANCE_M = 0.02; +/** 교차점 탐색 간격(m) — 횡단 샘플 간격보다 촘촘하게 훑는다. */ +const SCAN_STEP_M = 0.1; /** 추세 기울기를 재는 구간 길이(m). */ const TREND_SPAN_M = 2; /** 외삽 결과의 상한(m) — 발산하는 지형에서 폭이 무한정 커지는 것을 막는다. */ @@ -35,33 +37,83 @@ export function toeFitHalfWidth(section: CrossSection): number | null { const designAt = designInterpolator(design.design_line); const edges = design.road_edges; if (!groundAt || !designAt || !edges) return null; - const offsets = section.samples.map((sample) => sample.offset_m ?? 0); - const limits = { left: Math.max(...offsets), right: Math.min(...offsets) }; + + // 설계선이 실제로 정의된 범위 — 그 밖은 외삽 영역이다. + const lineOffsets = design.design_line.map((point) => point.offset_m); + if (lineOffsets.length < 2) return null; + const lineMax = Math.max(...lineOffsets); + const lineMin = Math.min(...lineOffsets); + // 노면·측구 구간은 건너뛴다 — 계획고가 지반과 우연히 겹쳐도 사면 끝이 아니다. + const { protectMax, protectMin } = protectedSpan(design, edges); + let extent = 0; for (const side of ["left", "right"] as const) { - const edge = side === "left" ? edges.left : edges.right; - const limit = limits[side]; - const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limit); - // 샘플 끝에서 멈췄고 아직 지반과 만나지 않았으면 추세로 더 밖을 추정한다. - const stopped = Math.abs(toe - limit) <= EDGE_TOLERANCE_M; - const reach = stopped ? extrapolateMeet(designAt, groundAt, limit, edge.offset_m) : toe; - extent = Math.max(extent, Math.abs(reach)); + const outward = side === "left" ? 1 : -1; + const start = side === "left" ? protectMax : protectMin; + const limit = side === "left" ? lineMax : lineMin; + const meet = meetOffset(designAt, groundAt, start, limit, outward); + extent = Math.max(extent, Math.abs(meet)); } if (!(extent > 0)) return null; return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M); } +/** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */ +function protectedSpan( + design: CrossDesign, + edges: { left: { offset_m: number }; right: { offset_m: number } }, +): { protectMax: number; protectMin: number } { + const leftEdge = Math.max(edges.left.offset_m, edges.right.offset_m); + const rightEdge = Math.min(edges.left.offset_m, edges.right.offset_m); + const spec = design.ditch; + const ditchWidth = + design.ditch_enabled === false || !spec || spec.type === "none" + ? 0 + : spec.type === "standard" + ? spec.top_width_m + : spec.width_m; + // 좌표 규약: +offset = 좌. ditch_side "left" = 큰 offset 쪽. + return { + protectMax: leftEdge + (design.ditch_side === "left" ? ditchWidth : 0), + protectMin: rightEdge - (design.ditch_side === "right" ? ditchWidth : 0), + }; +} + /** - * 샘플 끝(limit)에서 계획 사면과 지반의 기울기 차로 만나는 자리를 외삽한다. + * 노견 바깥으로 훑어 설계선이 지반과 처음 만나는 offset. 설계선 끝까지 안 만나면 + * 끝단 추세로 외삽한다(그래도 못 만나면 설계선 끝). + */ +function meetOffset( + designAt: (offset: number) => number, + groundAt: (offset: number) => number, + startOffset: number, + limitOffset: number, + outward: 1 | -1, +): number { + const span = Math.abs(limitOffset - startOffset); + if (!(span > 0)) return startOffset; + const steps = Math.ceil(span / SCAN_STEP_M); + let previousDiff = designAt(startOffset) - groundAt(startOffset); + for (let index = 1; index <= steps; index += 1) { + const offset = startOffset + outward * Math.min(SCAN_STEP_M * index, span); + const diff = designAt(offset) - groundAt(offset); + if (Math.abs(diff) <= MEET_TOLERANCE_M) return offset; + if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset; + previousDiff = diff; + } + return extrapolateMeet(designAt, groundAt, limitOffset, outward); +} + +/** + * 설계선 끝(limit)에서 사면과 지반의 기울기 차로 만나는 자리를 외삽한다. * 벌어지기만 하면(차이가 줄지 않으면) 끝 자리를 그대로 돌려준다 — 무한정 넓히지 않는다. */ function extrapolateMeet( designAt: (offset: number) => number, groundAt: (offset: number) => number, limit: number, - startOffset: number, + outward: 1 | -1, ): number { - const outward = Math.sign(limit - startOffset) || 1; const back = limit - outward * TREND_SPAN_M; const diffAtLimit = designAt(limit) - groundAt(limit); const diffAtBack = designAt(back) - groundAt(back); @@ -72,3 +124,8 @@ function extrapolateMeet( if (!Number.isFinite(extra)) return limit; return limit + outward * Math.min(extra, MAX_FIT_HALF_WIDTH_M); } + +/** 전 측점 중 교차점을 보려면 필요한 최대 반폭(m). 없으면 0. */ +export function maxToeFitHalfWidth(sections: CrossSection[]): number { + return sections.reduce((max, section) => Math.max(max, toeFitHalfWidth(section) ?? 0), 0); +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 9de63027..d40b136f 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -119,12 +119,22 @@ export function createCrossSectionCard( /** 유출측 추가 기슭막이 개수 제어(2026-08-22). */ extraWalls?: ExtraWallControl, ): CrossCardElement { - // 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). **구조물 없는 상태**의 절·성토선이 - // 원지반과 만나는 지점이 반폭 밖이면 이 카드만 자동 줌아웃(2026-08-22 사용자). + // 실효 표시 반폭 — 개별값 > 전역값(2026-08-06). 절·성토선이 원지반과 만나는 지점 + // (교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다(2026-08-22, 판정 기준 개편 + // 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점 = toeFitHalfWidth). + // 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우 + // [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다. const baseHalfWidth = stationWidth?.widthFor(section) ?? crossHalfWidth; - const requiredHalfWidth = culvertRequiredHalfWidth(section); + const sampledExtent = Math.max( + 0, + ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), + ); + const requiredHalfWidth = Math.min( + Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0), + sampledExtent, + ); const effectiveHalfWidth = - baseHalfWidth !== undefined && requiredHalfWidth !== null + baseHalfWidth !== undefined && requiredHalfWidth > 0 ? Math.max(baseHalfWidth, requiredHalfWidth) : baseHalfWidth; const card: CrossCardElement = document.createElement("article"); diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 3cd56be6..a9e4fb4d 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -30,6 +30,7 @@ import { type StandardCrossSection, } from "./B06_Section_Api_Fetch"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; +import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { type CrossDesignChange, @@ -285,8 +286,11 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { */ async function applyCrossHalfWidth(): Promise { if (!sectionDetail) return; + // 교차점이 샘플 밖인 측점이 있으면 그 폭까지 넓힌다(카드가 자동 줌아웃할 지반 확보). const requested = crossHalfWidth(); - if (requested !== undefined && !(await ensureSampledWidth(requested))) return; + const needed = maxToeFitHalfWidth(sectionDetail.cross_sections); + const target = Math.max(requested ?? 0, needed); + if (target > 0 && !(await ensureSampledWidth(target))) return; if (!sectionDetail) return; stationControls.applyGlobalWidth( requested,