fix(B06): 교차점 자동 줌아웃이 5m 사면 규칙에 잘리던 문제

자동 줌아웃 판정이 배수관용 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) <noreply@anthropic.com>
This commit is contained in:
2026-08-23 18:54:38 +09:00
co-authored by Claude Opus 5
parent 91f843b5bb
commit eb41202bf4
3 changed files with 100 additions and 29 deletions
+81 -24
View File
@@ -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);
}
+14 -4
View File
@@ -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");
+5 -1
View File
@@ -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<void> {
*/
async function applyCrossHalfWidth(): Promise<void> {
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,