- 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`apexLock`) - 계획 횡단선을 절·성토 교차점에서 끊음 — 그 밖은 원지반이라 지반선과 겹쳐 그려지던 것. 교차점은 B06 이 쓰는 `toeOffsets` 를 열어 함께 씀 - 횡단 판을 캔버스에서 SVG 로 바꾸고 휠 줌·가운데 끌기 팬·더블클릭 원복을 붙임 - 줌·팬 본문을 `A00_Common/b_svg_zoom_pan.ts` 공용 조각으로 빼고 B06 은 감싸개만 남김 - 「이전 횡단」 판은 계획 평면선이 바뀔 때만 채움(같은 측점 두 번 누르기로는 안 채움) - 「거리 재기」 단추의 눌린 꼴 추가 · 등고선 높이 라벨을 맨 위로 올리고 더 촘촘히 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
273 lines
13 KiB
TypeScript
273 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Cross_Fit.ts
|
|
* 횡단 카드 「교차점 맞춤」 — 절토·성토 사면이 원지반과 만나는 지점(교차점)이 모두
|
|
* 보이는 표시 반폭(m)을 구한다.
|
|
*
|
|
* 판정은 **설계선(design_line)과 지반선의 실제 교차점**으로 한다. 배수관 세트가 쓰는
|
|
* `slopeToeOffset`은 「성토사면 5m」 규정에서 잘라 돌려주므로(기슭막이 자리 계산용)
|
|
* 사면이 5m를 넘는 측점에서 필요 폭을 과소 산정했다 — 그 값으로 자동 줌아웃을 걸면
|
|
* 교차점이 여전히 화면 밖에 남는다(2026-08-23 사용자 보고).
|
|
*
|
|
* 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면
|
|
* 기울기와 지반 기울기 차이로 만나는 자리를 추정하고 1m 올림한다. 그 값이 계산 반폭을
|
|
* 넘으면 페이지가 백엔드 재생성으로 샘플을 넓힌다.
|
|
* ========================================================================== */
|
|
|
|
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
|
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
|
|
|
/** 설계선과 지반선이 만났다고 보는 표고 차(m). */
|
|
const MEET_TOLERANCE_M = 0.02;
|
|
/** 교차점 탐색 간격(m) — 횡단 샘플 간격보다 촘촘하게 훑는다. */
|
|
const SCAN_STEP_M = 0.1;
|
|
/** 추세 기울기를 재는 구간 길이(m). */
|
|
const TREND_SPAN_M = 2;
|
|
/** 외삽 결과의 상한(m) — 발산하는 지형에서 폭이 무한정 커지는 것을 막는다. */
|
|
const MAX_FIT_HALF_WIDTH_M = 60;
|
|
|
|
/**
|
|
* 절·성토 교차점이 모두 들어오는 표시 반폭(m). 설계선·지반선이 없으면 null.
|
|
*
|
|
* 반환값은 1m 단위 올림 — 교차점이 축선에 딱 붙지 않게 한다.
|
|
*/
|
|
export function toeFitHalfWidth(section: CrossSection): number | null {
|
|
const design = section.design;
|
|
if (!design) return null;
|
|
const groundAt = groundInterpolator(section.samples);
|
|
const designAt = designInterpolator(design.design_line);
|
|
const edges = design.road_edges;
|
|
if (!groundAt || !designAt || !edges) return null;
|
|
|
|
// 설계선이 실제로 정의된 범위 — 그 밖은 외삽 영역이다.
|
|
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 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);
|
|
}
|
|
|
|
/**
|
|
* 절·성토 사면이 원지반과 **실제로 만나는** 좌·우 offset(m). 그 측에서 못 만나면 null.
|
|
*
|
|
* `toeFitHalfWidth` 와 같은 교차점을 쓰되 **좌우를 따로** 낸다(2026-09-12 사용자 지시 ②) —
|
|
* 반폭 하나로 자르면 한쪽만 성토인 측점에서 반대쪽 원지반이 필요보다 넓게 남는다.
|
|
* **외삽으로 짚은 자리는 안 쓴다**(null) — 지반을 모르는 자리라 거기까지 그으면 없는 지반을
|
|
* 그리는 셈이 된다.
|
|
*/
|
|
export function toeOffsets(section: CrossSection): { left: number | null; right: number | null } {
|
|
const out: { left: number | null; right: number | null } = { left: null, right: null };
|
|
const design = section.design;
|
|
if (!design) return out;
|
|
const groundAt = groundInterpolator(section.samples);
|
|
const designAt = designInterpolator(design.design_line);
|
|
const edges = design.road_edges;
|
|
if (!groundAt || !designAt || !edges) return out;
|
|
const lineOffsets = design.design_line.map((point) => point.offset_m);
|
|
if (lineOffsets.length < 2) return out;
|
|
const { protectMax, protectMin } = protectedSpan(design, edges);
|
|
for (const side of ["left", "right"] as const) {
|
|
const outward = side === "left" ? 1 : -1;
|
|
const start = side === "left" ? protectMax : protectMin;
|
|
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
|
|
const meet = meetOffset(designAt, groundAt, start, limit, outward);
|
|
// 설계선 끝 밖으로 나간 값은 외삽이다 — 만난 것으로 치지 않는다.
|
|
if (Math.abs(meet) > Math.abs(limit) + 1e-9) continue;
|
|
if (Math.abs(designAt(meet) - groundAt(meet)) > MEET_TOLERANCE_M) continue;
|
|
out[side] = meet;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 성토측 사면의 **본래** 경사길이(m) — 성토가 아닌 측은 null (2026-09-03 사용자 지시).
|
|
*
|
|
* 「본래」 = 구조물(기슭막이·집수정)이 사면을 끊기 전 표준 횡단 기준. 구조물은 프론트
|
|
* 오버레이라 백엔드 `design_line`을 바꾸지 않으므로, 그 선으로 재면 곧 본래 길이다.
|
|
* 구간은 노견 끝(그 측에 측구가 있으면 측구 바깥)부터 설계선이 원지반과 처음 만나는
|
|
* 점까지이고, 그 사이 성토선은 1:n 직선이라 수평 성분만으로 경사길이가 나온다.
|
|
*/
|
|
export interface FillSlopeLength {
|
|
/** 사면 경사길이(m). 미교차(`open`)면 계산 반폭까지의 **하한값**이다. */
|
|
lengthM: number;
|
|
/** 설계선 끝(계산 반폭)까지 원지반과 만나지 못한 사면 — 길이를 다 재지 못했다. */
|
|
open: boolean;
|
|
}
|
|
|
|
export function fillSlopeLengths(section: CrossSection): {
|
|
left: FillSlopeLength | null;
|
|
right: FillSlopeLength | null;
|
|
} {
|
|
const lengths: { left: FillSlopeLength | null; right: FillSlopeLength | null } = {
|
|
left: null,
|
|
right: null,
|
|
};
|
|
const design = section.design;
|
|
if (!design) return lengths;
|
|
const groundAt = groundInterpolator(section.samples);
|
|
const designAt = designInterpolator(design.design_line);
|
|
const edges = design.road_edges;
|
|
if (!groundAt || !designAt || !edges) return lengths;
|
|
const lineOffsets = design.design_line.map((point) => point.offset_m);
|
|
if (lineOffsets.length < 2) return lengths;
|
|
const { protectMax, protectMin } = protectedSpan(design, edges);
|
|
const slant = Math.hypot(1, 1 / Math.max(design.fill_slope_ratio, 1e-6));
|
|
for (const side of ["left", "right"] as const) {
|
|
const mode = design.section_mode;
|
|
if (mode === "both_cut" || mode === `${side}_cut`) continue;
|
|
const outward = side === "left" ? 1 : -1;
|
|
const start = side === "left" ? protectMax : protectMin;
|
|
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
|
|
// 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `toeFitHalfWidth`가
|
|
// 쓰는 추세 외삽은 지반이 사면과 거의 나란한 자리에서 수십 m씩 튀어 길이 표기에는 못 쓴다
|
|
// (2026-09-03 실측: 740m 측점 좌측 외삽 101.53m, 실제 반폭 20m까지 고저차 7.16m 유지).
|
|
const meet = meetOffset(designAt, groundAt, start, limit, outward);
|
|
const end = Math.abs(meet) > Math.abs(limit) ? limit : meet;
|
|
lengths[side] = {
|
|
// 소단이 있으면 사면이 계단으로 끊기므로 **구간별 최대**를 잰다. 없으면 종전대로
|
|
// 수평 성분 × 기울기 — 값이 한 톨도 안 바뀐다(2026-09-07, 계획서 3-9).
|
|
lengthM: design.berm
|
|
? longestFillRun(design, start, end, slant)
|
|
: Math.abs(end - start) * slant,
|
|
open: Math.abs(designAt(end) - groundAt(end)) > MEET_TOLERANCE_M,
|
|
};
|
|
}
|
|
return lengths;
|
|
}
|
|
|
|
/**
|
|
* 소단으로 끊긴 성토사면에서 **가장 긴 한 구간**의 경사길이(m).
|
|
*
|
|
* 왜 최대인가 — 법정 기준은 「성토사면 길이 5m 이내, 넘으면 옹벽·석축 의무」다
|
|
* (`성토_비탈면.md` §2). 소단은 사면을 끊는 시설이므로, 판정은 **끊긴 한 도막**을 봐야 한다.
|
|
* 전체를 한 줄로 재면 소단을 넣어도 5m 를 넘어 **의무가 사라지지 않고**, 반대로 실효 경사로
|
|
* 재면 완만해져 **의무가 사라진 것처럼** 보인다. 둘 다 틀린다.
|
|
*
|
|
* 평탄부(소단)는 길이에 안 넣고 그 자리에서 도막을 끊는다.
|
|
*/
|
|
function longestFillRun(
|
|
design: CrossDesign,
|
|
startOffset: number,
|
|
endOffset: number,
|
|
slant: number,
|
|
): number {
|
|
const berm = design.berm;
|
|
if (!berm) return Math.abs(endOffset - startOffset) * slant;
|
|
const low = Math.min(startOffset, endOffset);
|
|
const high = Math.max(startOffset, endOffset);
|
|
// 소단인지는 **기울기**로 가른다 — 폭으로 찾으면 못 찾는다. 설계선 꼭짓점에는 지반
|
|
// 샘플(0.5m 격자)이 섞여 있어 폭 0.5m 짜리 소단이 두 도막으로 **쪼개지기** 때문이다
|
|
// (2026-09-07 실측: 폭으로 찾으니 평탄부 0개). 소단 기울기는 2°(0.035)이고 성토 기울기는
|
|
// 1:1.2~2.0(0.5~0.83)이라 절반만 잡아도 둘이 확실히 갈린다.
|
|
const fillGradient = 1 / Math.max(design.fill_slope_ratio, 1e-6);
|
|
const bermMaxGradient = fillGradient * 0.5;
|
|
let longest = 0;
|
|
let current = 0;
|
|
const line = design.design_line;
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
const a = line[index - 1];
|
|
const b = line[index];
|
|
const from = Math.max(Math.min(a.offset_m, b.offset_m), low);
|
|
const to = Math.min(Math.max(a.offset_m, b.offset_m), high);
|
|
if (to - from <= 1e-9) continue; // 이 도막은 사면 밖이다
|
|
const run = Math.abs(b.offset_m - a.offset_m);
|
|
if (run <= 1e-9) continue;
|
|
const gradient = Math.abs(b.elevation_m - a.elevation_m) / run;
|
|
if (gradient < bermMaxGradient) {
|
|
longest = Math.max(longest, current); // 소단에서 도막이 끊긴다
|
|
current = 0;
|
|
continue;
|
|
}
|
|
current += (to - from) * slant;
|
|
}
|
|
return Math.max(longest, current);
|
|
}
|
|
|
|
/** 노면·노견(+측구) 구간의 바깥 경계 — 교차점 탐색은 여기서부터 바깥으로 간다. */
|
|
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),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 노견 바깥으로 훑어 설계선이 지반과 처음 만나는 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)) {
|
|
// 두 걸음 사이를 선형보간한다 — 반폭 맞춤에는 영향이 없지만(1m 올림), 같은 교차점을
|
|
// 길이로 읽는 성토사면 표기가 탐색 간격(0.1m)만큼 튀는 것을 막는다(2026-09-03).
|
|
const previous = offset - outward * SCAN_STEP_M;
|
|
return previous + (offset - previous) * (previousDiff / (previousDiff - diff));
|
|
}
|
|
previousDiff = diff;
|
|
}
|
|
return extrapolateMeet(designAt, groundAt, limitOffset, outward);
|
|
}
|
|
|
|
/**
|
|
* 설계선 끝(limit)에서 사면과 지반의 기울기 차로 만나는 자리를 외삽한다.
|
|
* 벌어지기만 하면(차이가 줄지 않으면) 끝 자리를 그대로 돌려준다 — 무한정 넓히지 않는다.
|
|
*/
|
|
function extrapolateMeet(
|
|
designAt: (offset: number) => number,
|
|
groundAt: (offset: number) => number,
|
|
limit: number,
|
|
outward: 1 | -1,
|
|
): number {
|
|
const back = limit - outward * TREND_SPAN_M;
|
|
const diffAtLimit = designAt(limit) - groundAt(limit);
|
|
const diffAtBack = designAt(back) - groundAt(back);
|
|
// 바깥으로 갈수록 차이가 줄어드는 속도(m/m). 0 이하이면 만나지 않는다.
|
|
const closingRate = (Math.abs(diffAtBack) - Math.abs(diffAtLimit)) / TREND_SPAN_M;
|
|
if (!(closingRate > 1e-6)) return limit;
|
|
const extra = Math.abs(diffAtLimit) / closingRate;
|
|
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);
|
|
}
|