Files
Aislo/common_util/common_util_cross_design_geometry.ts
T
eomsangdonandClaude Opus 5 5cc5329cac fix(B06): 자동과 같은 측구 선택은 「자동」으로 풀림 — 다시 굳지 않게
화면 토글이 **2단**이라 「자동으로 되돌리기」 단추가 없음. 되돌리려면 원래 값으로 다시
누르는 수밖에 없는데, 그것을 선택으로 굳히면 **방금 고친 병이 그대로 되돌아옴**
(지형이 바뀌어도 안 따라감).

⇒ 선택이 자동값과 같으면 **선택 없음(자동)** 으로 본다. 파이썬·TS 짝 양쪽.
시험 하나 추가 — 「자동과 같은 선택은 자동으로 푼다」. 전체 617 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 13:18:21 +09:00

487 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* common_util_cross_design_geometry.ts
* 표준횡단 단면 기하 — 노면·측구·사면 순으로 offset 의 설계고를 계산한다.
*
* ⚠ 짝 파일 `B06_Section/B06_Section_Engine_Design.py` 의 `_SectionGeometry` 와 한 벌이다.
* `common_util_cross_design.ts` 가 700줄을 넘어 떼어냈다(2026-09-04) — 계산은 그대로다.
* ========================================================================== */
import {
type BermSpec,
cutProfilePoints,
elevationAt,
fillProfilePoints,
} from "./common_util_cross_berm";
/** 절토 사면 경사 구간 한 칸 — 짝 파이썬 `cut_slope_segments` 와 같은 항목. */
export interface CutSlopeSegment {
side: string;
ratio: number;
rise_m: number;
run_m: number;
start_offset_m: number;
end_offset_m: number;
material: string | null;
}
/** 파이썬 `round(x, 4)` 와 같은 자리 맞춤. */
function round4(value: number): number {
return Math.round(value * 10000) / 10000;
}
/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */
const MARCH_STEP_M = 0.05;
const CROSS_MAX_M = 500;
export interface ResolvedGroup {
road_width_m: number;
shoulder_left_m: number;
shoulder_right_m: number;
ditch_top_width_m: number;
ditch_bottom_width_m: number;
ditch_depth_m: number;
l_ditch_width_m: number;
l_ditch_depth_m: number;
cross_slope_pct: number;
fill_slope_ratio: number;
cut_slope_ratio: number;
pavement_thickness_m: number;
}
/** 짝: `_side_role`. 단면유형 → [좌측 역할, 우측 역할]. */
export function sideRole(sectionMode: string): [string, string] {
if (sectionMode === "left_cut") return ["cut", "fill"];
if (sectionMode === "right_cut") return ["fill", "cut"];
if (sectionMode === "both_cut") return ["cut", "cut"];
if (sectionMode === "both_fill") return ["fill", "fill"];
throw new Error(`지원하지 않는 단면유형입니다: ${sectionMode}`);
}
/**
* 곡선부 너비 확폭표 — **짝: `config_system_design.CURVE_WIDENING_TABLE_M`**
* (별표2 .2.나.(4)). `[반경 하한, 반경 상한(미만), 확폭(m)]`이고 45m 이상은 확폭이 없다.
* 두 벌이 한 세트이므로 값을 고칠 때는 파이썬 쪽도 함께 고친다.
*/
const CURVE_WIDENING_TABLE_M: ReadonlyArray<readonly [number, number, number]> = [
[10, 13, 2.25],
[13, 14, 2.0],
[14, 15, 1.75],
[15, 18, 1.5],
[18, 20, 1.25],
[20, 25, 1.0],
[25, 30, 0.75],
[30, 40, 0.5],
[40, 45, 0.25],
];
/** 확폭을 더한 뒤의 유효너비 상한(m) — 짝: `CURVE_WIDENING_MAX_WIDTH_M`. */
export const CURVE_WIDENING_MAX_WIDTH_M = 5.0;
/** 평면 곡선반경으로 확폭량(m)을 정한다. 직선·45m 이상·값 없음은 0. */
export function curveWideningM(planRadiusM: number | null | undefined): number {
if (planRadiusM === null || planRadiusM === undefined || !Number.isFinite(planRadiusM)) return 0;
const found = CURVE_WIDENING_TABLE_M.find(
([low, high]) => planRadiusM >= low && planRadiusM < high,
);
return found ? found[2] : 0;
}
/** 짝: `_SectionGeometry`. 노면 → 측구 → 사면 순으로 offset 의 설계고를 계산한다. */
export class SectionGeometry {
/** 규격 차도 반폭(확폭 전) — 수량·표기 기준. */
halfRoad: number;
/** 좌(+)·우(−) 차도 반폭 — 곡선부 확폭이 **한쪽에만** 붙어 좌우가 갈린다(2026-09-06). */
halfRoadLeft: number;
halfRoadRight: number;
leftExtent: number;
rightExtent: number;
zCenter: number;
cutRatio: number;
fillRatio: number;
leftRole: string;
rightRole: string;
ditchSide: string;
soilCutRatio: number;
twoStage: boolean;
ditchType: string;
slopePerOffset: number;
hasDitch: boolean;
/** 사용자가 정한 선택(`null` = 자동). 결과(`hasDitch`)와 다른 값이다. */
ditchChoice: boolean | null;
ditchPoints: Array<[number, number]> = [];
private groundAt: ((offsetM: number) => number) | null;
private rockOffset: number;
/** 소단 제원(없으면 null) — 절토 사면 꼭짓점 셈에 그대로 넘어간다. */
berm: BermSpec | null = null;
private cutPointsCache = new Map<string, Array<[number, number]>>();
private fillPointsCache = new Map<string, Array<[number, number]>>();
private cutCross = new Map<string, number | null>();
private fillCross = new Map<string, number | null>();
constructor(params: {
designElevationM: number;
group: ResolvedGroup;
sectionMode: string;
ditchSide: string;
ditchType: string;
crossSlopePct: number;
groundAt: ((offsetM: number) => number) | null;
soilCutRatio: number | null;
rockBoundaryOffsetM: number | null;
twoStageSlope: boolean;
/** 옛 저장분의 결과값 — **자동값과 다를 때만** 선택으로 살린다. */
ditchEnabled: boolean | null;
/** 사용자가 정한 선택(없으면 자동). */
ditchChoice?: boolean | null;
/** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */
wideningLeftM?: number;
wideningRightM?: number;
/** 소단 제원 — 없으면 계단 없이 종전 사면 그대로(계획서 3-9). */
berm?: BermSpec | null;
}) {
const { group } = params;
const halfRoad = group.road_width_m / 2;
this.halfRoad = halfRoad;
this.halfRoadLeft = halfRoad + Math.max(params.wideningLeftM ?? 0, 0);
this.halfRoadRight = halfRoad + Math.max(params.wideningRightM ?? 0, 0);
this.leftExtent = this.halfRoadLeft + group.shoulder_left_m;
this.rightExtent = this.halfRoadRight + group.shoulder_right_m;
this.zCenter = params.designElevationM;
this.cutRatio = Math.max(group.cut_slope_ratio, 1e-6);
this.fillRatio = Math.max(group.fill_slope_ratio, 1e-6);
[this.leftRole, this.rightRole] = sideRole(params.sectionMode);
this.ditchSide = params.ditchSide;
this.soilCutRatio = Math.max(params.soilCutRatio ?? group.cut_slope_ratio, 1e-6);
this.twoStage = Boolean(
params.twoStageSlope && params.groundAt !== null && params.rockBoundaryOffsetM !== null,
);
this.groundAt = params.groundAt;
this.berm = params.berm ?? null;
this.rockOffset = params.rockBoundaryOffsetM ?? 0;
this.ditchType = params.ditchType;
// 횡단경사: 측구 방향으로 내려가는 단일 사면 (좌=+offset 규약).
const slope = params.crossSlopePct / 100;
this.slopePerOffset = params.ditchSide === "left" ? -slope : slope;
// 단면유형 자동 판정(D-2) — 노면 끝 지반이 설계면보다 높으면 절토, 낮으면 성토.
const groundAt = params.groundAt;
if (groundAt !== null) {
this.leftRole =
groundAt(this.leftExtent) > this.roadZ(this.leftExtent) + 1e-3 ? "cut" : "fill";
this.rightRole =
groundAt(-this.rightExtent) > this.roadZ(-this.rightExtent) + 1e-3 ? "cut" : "fill";
}
// 측구 생성 여부(D-1) — **자동 판정이 먼저, 사용자 선택이 그 위**(2026-09-09 정리).
//
// ⚠⚠ **한 칸에 두 뜻이 담겨 있던 자리다**(짝: 파이썬 `_SectionGeometry`). 결과
// (`ditch_enabled` = 실제 생성됨)를 그대로 다시 입력으로 넣어 읽었으므로 **한 번
// 저장되면 자동 판정이 영영 다시 안 돌았다.** 계획고를 내려 절토가 생겨도 측구가
// 안 서고 아무 말도 안 나왔다. ⇒ **선택은 `ditchChoice`**(없음 = 자동), **결과는
// `hasDitch`** 로 가른다.
// ⚠ 옛 저장분은 `ditchEnabled` 로 온다. 그 값은 **자동값과 다를 때만 뜻이 있다** —
// 같으면 자동이 그렇게 냈던 것이고, 다르면 사용자가 일부러 바꾼 것이다.
let autoDitch: boolean;
if (params.sectionMode === "both_fill") {
autoDitch = false;
} else if (groundAt !== null) {
const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
autoDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3;
} else {
autoDitch = true; // groundAt 이 없으면 보수적으로 생성
}
let choice = params.ditchChoice ?? null;
const legacy = params.ditchEnabled;
if (choice === null && legacy !== null && legacy !== undefined) {
choice = Boolean(legacy);
}
// ⚠ **자동과 같은 값을 고른 것은 「자동」으로 본다**(짝: 파이썬). 토글이 2단이라
// 「자동으로 되돌리기」 단추가 없고, 원래 값으로 다시 누른 것을 선택으로 굳히면
// **다시 같은 병**이 된다.
if (choice !== null && Boolean(choice) === autoDitch) choice = null;
// 양성(both_fill)은 측구가 설 자리가 없다 — 선택보다 기하가 먼저다.
this.hasDitch = choice === null || params.sectionMode === "both_fill" ? autoDitch : choice;
/** 그 측점에 **사용자가 정한 선택**(없으면 자동). 결과와 갈라 내보낸다. */
this.ditchChoice = choice;
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
const edgeOffset = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
const outward = params.ditchSide === "left" ? 1 : -1;
const edgeZ = this.roadZ(edgeOffset);
if (this.hasDitch) {
if (params.ditchType === "l_type") {
// L형: 노면 끝에서 폭 W 동안 깊이 D로 내려가는 경사 바닥 + 바깥 수직벽.
const width = group.l_ditch_width_m;
const depth = group.l_ditch_depth_m;
this.ditchPoints = [
[edgeOffset, edgeZ],
[edgeOffset + outward * width, edgeZ - depth],
[edgeOffset + outward * width, edgeZ],
];
} else {
// 일반: 상단폭/저폭/깊이 사다리꼴.
const top = group.ditch_top_width_m;
const bottom = Math.min(group.ditch_bottom_width_m, top);
const depth = group.ditch_depth_m;
const inset = (top - bottom) / 2;
this.ditchPoints = [
[edgeOffset, edgeZ],
[edgeOffset + outward * inset, edgeZ - depth],
[edgeOffset + outward * (inset + bottom), edgeZ - depth],
[edgeOffset + outward * top, edgeZ],
];
}
}
}
/** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */
roadZ(offsetM: number): number {
return this.zCenter + this.slopePerOffset * offsetM;
}
/** 짝: `_slope_start`. 사면 시작점 [오프셋 절대값 거리, 표고]. */
private slopeStart(side: string): [number, number] {
const edgeOffset = side === "left" ? this.leftExtent : this.rightExtent;
const edgeZ = side === "left" ? this.roadZ(this.leftExtent) : this.roadZ(-this.rightExtent);
if (side === this.ditchSide && this.ditchPoints.length) {
const outer = this.ditchPoints[this.ditchPoints.length - 1];
return [Math.abs(outer[0]), outer[1]];
}
return [edgeOffset, edgeZ];
}
/** 짝: `_rock_boundary_z`. 암반 경계선 표고 = 지반선 + 오프셋(음수=하향). */
private rockBoundaryZ(side: string, dist: number): number {
const signed = side === "left" ? dist : -dist;
return (this.groundAt as (offsetM: number) => number)(signed) + this.rockOffset;
}
/** 짝: `cut_points`. 절토 사면 꼭짓점 — 무릎과 소단이 모두 여기 들어 있다. */
cutPoints(side: string): Array<[number, number]> {
const cached = this.cutPointsCache.get(side);
if (cached !== undefined) return cached;
const [startDist, startZ] = this.slopeStart(side);
const boundary = this.twoStage ? (dist: number) => this.rockBoundaryZ(side, dist) : null;
const points = cutProfilePoints(
startDist,
startZ,
this.cutRatio,
this.soilCutRatio,
boundary,
this.berm,
undefined,
// 소단이 있으면 경계를 오갈 때마다 꺾는다 — 짝 파이썬과 같은 이유(2026-09-07).
this.berm !== null,
);
this.cutPointsCache.set(side, points);
return points;
}
/** 짝: `_cut_slope_z`. 절토 사면선 표고(무릎·소단 반영, 지반 클램프 없음). */
private cutSlopeZ(side: string, dist: number): number {
return elevationAt(this.cutPoints(side), dist);
}
/** 짝: `fill_points`. 성토 사면 꼭짓점 — 소단이 들어 있다(무릎은 없다). */
fillPoints(side: string): Array<[number, number]> {
const cached = this.fillPointsCache.get(side);
if (cached !== undefined) return cached;
const [startDist, startZ] = this.slopeStart(side);
const points = fillProfilePoints(startDist, startZ, this.fillRatio, this.berm);
this.fillPointsCache.set(side, points);
return points;
}
/** 짝: `_fill_slope_z`. 성토 사면선 표고(소단 반영, 지반 클램프 없음). */
private fillSlopeZ(side: string, dist: number): number {
return elevationAt(this.fillPoints(side), dist);
}
/** 짝: `cut_cross_dist`. 절토 사면이 지반선과 처음 만나는 거리(N-2-4). */
cutCrossDist(side: string): number | null {
const cached = this.cutCross.get(side);
if (cached !== undefined) return cached;
let result: number | null = null;
if (this.groundAt !== null) {
const [startDist] = this.slopeStart(side);
let dist = startDist;
const maxDist = startDist + CROSS_MAX_M;
while (dist <= maxDist) {
const signed = side === "left" ? dist : -dist;
if (this.cutSlopeZ(side, dist) - this.groundAt(signed) >= 0) {
result = dist;
break;
}
dist += MARCH_STEP_M;
}
}
this.cutCross.set(side, result);
return result;
}
/** 짝: `fill_cross_dist`. 성토 사면이 지반선과 처음 만나는 거리. */
fillCrossDist(side: string): number | null {
const cached = this.fillCross.get(side);
if (cached !== undefined) return cached;
let result: number | null = null;
if (this.groundAt !== null) {
const [startDist] = this.slopeStart(side);
let dist = startDist;
const maxDist = startDist + CROSS_MAX_M;
while (dist <= maxDist) {
const signed = side === "left" ? dist : -dist;
const fillLine = this.fillSlopeZ(side, dist);
if (fillLine - this.groundAt(signed) <= 0) {
result = dist;
break;
}
dist += MARCH_STEP_M;
}
}
this.fillCross.set(side, result);
return result;
}
/** 짝: `fill_ground_slope`. 성토측 자연 지반 평균 경사(rise/run). */
fillGroundSlope(): number | null {
if (this.groundAt === null) return null;
const slopes: number[] = [];
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "fill") continue;
const [startDist] = this.slopeStart(side);
const endDist = this.fillCrossDist(side) ?? startDist + 10;
const run = endDist - startDist;
if (run <= 1e-6) continue;
const sign = side === "left" ? 1 : -1;
const rise = Math.abs(this.groundAt(sign * endDist) - this.groundAt(sign * startDist));
slopes.push(rise / run);
}
return slopes.length ? Math.min(...slopes) : null;
}
/** 짝: `design_z`. offset 하나의 설계 표고(사면은 지반 교차점 이후 지반 추종). */
designZ(offsetM: number, groundM: number): number {
const side = offsetM >= 0 ? "left" : "right";
const extent = side === "left" ? this.leftExtent : this.rightExtent;
if (Math.abs(offsetM) <= extent + 1e-9) return this.roadZ(offsetM);
// 측구 구간: 꼭짓점 사이 선형 보간(지반 무관 강제 굴착).
if (side === this.ditchSide && this.ditchPoints.length) {
const inner = Math.abs(this.ditchPoints[0][0]);
const outer = Math.abs(this.ditchPoints[this.ditchPoints.length - 1][0]);
if (Math.abs(offsetM) >= inner - 1e-9 && Math.abs(offsetM) <= outer + 1e-9) {
const points = this.ditchPoints;
for (let index = 1; index < points.length; index += 1) {
const x0 = Math.abs(points[index - 1][0]);
const z0 = points[index - 1][1];
const x1 = Math.abs(points[index][0]);
const z1 = points[index][1];
if (Math.abs(offsetM) > x1 + 1e-9) continue;
const span = x1 - x0;
if (span <= 1e-9) return z1;
return z0 + (z1 - z0) * ((Math.abs(offsetM) - x0) / span);
}
return points[points.length - 1][1];
}
}
const role = side === "left" ? this.leftRole : this.rightRole;
const dist = Math.abs(offsetM);
if (role === "cut") {
const cross = this.cutCrossDist(side);
if (cross !== null && dist >= cross) return groundM;
return Math.min(this.cutSlopeZ(side, dist), groundM);
}
const cross = this.fillCrossDist(side);
if (cross !== null && dist >= cross) return groundM;
return Math.max(this.fillSlopeZ(side, dist), groundM);
}
/**
* 짝: `cut_slope_segments`. 절토 사면을 **경사 구간별로** 쪼갠 목록.
* ⚠ 지금 읽는 곳은 없다(2026-09-07, 법정 검사 폐기). 소단 기하가 이 셈 위에 선다.
*
* 소단이 서면 사면 전체를 하나로 재는 「실효 경사」가 완만해져 위반이 사라진 것처럼
* 보인다. 검사는 소단을 뺀 **사면 구간 자체**를 봐야 하므로 그 구간을 내보낸다.
*/
cutSlopeSegments(): CutSlopeSegment[] {
const segments: CutSlopeSegment[] = [];
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "cut") continue;
const cross = this.cutCrossDist(side);
const points = this.cutPoints(side);
const sign = side === "left" ? 1 : -1;
for (let index = 1; index < points.length; index += 1) {
const [startD, startZ] = points[index - 1];
let [endD, endZ] = points[index];
if (cross !== null && startD >= cross - 1e-9) break; // 지반과 만난 뒤는 절토가 없다
if (cross !== null && endD > cross) {
endZ = elevationAt(points, cross);
endD = cross;
}
const run = endD - startD;
const rise = endZ - startZ;
if (run <= 1e-9 || rise <= 1e-6) continue;
if (this.berm !== null && Math.abs(run - this.berm.widthM) < 1e-6) {
const bermRise = Math.tan((this.berm.slopeDeg * Math.PI) / 180) * this.berm.widthM;
if (Math.abs(rise - bermRise) < 1e-9) continue; // 소단(평탄부)
}
// 재료는 **그 구간을 실제로 그린 경사비**로 가른다(짝 파이썬과 같은 까닭) —
// 경계선을 다시 재면 「경사비는 토사인데 재료는 암」인 구간이 생긴다.
let material: string | null = null;
if (this.twoStage && Math.abs(this.soilCutRatio - this.cutRatio) > 1e-9) {
const drawn = run / rise;
material =
Math.abs(drawn - this.soilCutRatio) < Math.abs(drawn - this.cutRatio) ? "soil" : "rock";
}
segments.push({
side,
ratio: round4(run / rise),
rise_m: round4(rise),
run_m: round4(run),
start_offset_m: round4(sign * startD),
end_offset_m: round4(sign * endD),
material,
});
}
}
return segments;
}
/** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */
breakpoints(): number[] {
const points = [0, this.leftExtent, -this.rightExtent];
for (const [offset] of this.ditchPoints) points.push(offset);
// 절토 사면 꼭짓점(무릎·소단 모서리) — 빠뜨리면 계단이 설계선에 안 실린다.
if (this.twoStage || this.berm !== null) {
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "cut") continue;
const cross = this.cutCrossDist(side);
for (const [offset] of this.cutPoints(side)) {
if (cross !== null && offset > cross + 1e-9) break; // 지반과 만난 뒤는 절토가 없다
points.push(side === "left" ? offset : -offset);
}
}
}
// 성토 사면 소단 모서리 — 절토와 같은 까닭으로 설계선에 실어야 계단이 그려진다.
if (this.berm !== null) {
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
if (role !== "fill") continue;
const cross = this.fillCrossDist(side);
for (const [offset] of this.fillPoints(side)) {
if (cross !== null && offset > cross + 1e-9) break;
points.push(side === "left" ? offset : -offset);
}
}
}
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side);
if (cross !== null) points.push(side === "left" ? cross : -cross);
}
return points;
}
}