Files
Aislo/common_util/common_util_cross_design_geometry.ts
T
eomsangdonandClaude Opus 5 283a248f89 feat(B06): 곡선부 노폭 자동 확폭 — 평면 R 기준, 곡선 바깥쪽 편측
2026-09-06 사용자 확정. 별표2 Ⅰ.2.나.(4) 확폭표(R 10~45m → 2.25~0.25m)를 측점별
평면 곡선반경에 물려 차도 폭을 넓힌다.

- 확폭 방향은 **곡선 바깥쪽 편측** — 노선 폴리라인의 외적 부호로 회전 방향을 보고
  바깥쪽을 정한다(좌회전이면 우측). 측점 기록에 `curve_outer_side` 로 실린다.
- 차도 반폭을 좌·우로 나눠 들어 한쪽만 넓어지게 함. 확폭이 0이면 예전과 같은 대칭
  단면이다. 노견·측구·사면은 그 바깥으로 그대로 밀린다.
- 확폭을 더한 유효너비는 법정 상한 5m 에서 자른다(규격 3.0m 면 최대 2.0m 까지).
- 계산 짝을 함께 고침 — 파이썬 `compute_cross_design` 과 브라우저 `computeCrossDesign`,
  표는 양쪽에 두되 짝임을 주석으로 못 박음. 확폭 입력은 측점 기록에서 뽑는 헬퍼
  하나로 9개 호출부(횡단·확정·B07 도면)에 같은 값이 가게 함.
- 횡단도에 노폭 라벨 — 확폭이 걸리면 「노폭 4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
- 확인: 표 경계·편측 적용·5m 상한·회전 방향 판정 5건(pytest) + 브라우저 표 15건 일치.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 12:07:56 +09:00

372 lines
15 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) — 계산은 그대로다.
* ========================================================================== */
/** 사면·경계 교차 탐색 행진 간격(m)과 최대 거리. 짝: 파이썬 `step`/`max_dist`. */
const MARCH_STEP_M = 0.05;
const KNEE_MAX_M = 200;
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;
ditchPoints: Array<[number, number]> = [];
private groundAt: ((offsetM: number) => number) | null;
private rockOffset: number;
private rockKnee = new Map<string, [number, number] | null>();
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;
/** 곡선부 확폭(m) — 붙는 쪽만 값이 있고 반대쪽은 0이다. */
wideningLeftM?: number;
wideningRightM?: number;
}) {
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.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).
if (params.sectionMode === "both_fill") {
this.hasDitch = false;
} else if (params.ditchEnabled !== null && params.ditchEnabled !== undefined) {
this.hasDitch = params.ditchEnabled;
} else if (groundAt !== null) {
const ditchEdge = params.ditchSide === "left" ? this.leftExtent : -this.rightExtent;
this.hasDitch = groundAt(ditchEdge) > this.roadZ(ditchEdge) + 1e-3;
} else {
this.hasDitch = true;
}
// 측구 꼭짓점(측구측 노면 끝 기준, 바깥 방향 부호 적용).
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;
}
/** 짝: `knee`. 절토 사면이 암반 경계선을 지나는 전환점(무릎). */
knee(side: string): [number, number] | null {
if (!this.twoStage) return null;
const cached = this.rockKnee.get(side);
if (cached !== undefined) return cached;
const [startDist, startZ] = this.slopeStart(side);
let diffPrev = startZ - this.rockBoundaryZ(side, startDist);
let result: [number, number] | null;
if (diffPrev >= 0) {
result = [startDist, startZ]; // 시작부터 토사(경계 위)
} else {
result = null;
let distPrev = startDist;
let dist = startDist + MARCH_STEP_M;
while (dist <= startDist + KNEE_MAX_M) {
const zRock = startZ + (dist - startDist) / this.cutRatio;
const diff = zRock - this.rockBoundaryZ(side, dist);
if (diff >= 0) {
const span = diff - diffPrev;
const ratio = Math.abs(span) > 1e-9 ? -diffPrev / span : 0;
const kneeDist = distPrev + (dist - distPrev) * ratio;
const kneeZ = startZ + (kneeDist - startDist) / this.cutRatio;
result = [kneeDist, kneeZ];
break;
}
distPrev = dist;
diffPrev = diff;
dist += MARCH_STEP_M;
}
}
this.rockKnee.set(side, result);
return result;
}
/** 짝: `_cut_slope_z`. 절토 사면선 표고(2단계 무릎 반영, 지반 클램프 없음). */
private cutSlopeZ(side: string, dist: number): number {
const [startDist, startZ] = this.slopeStart(side);
const knee = this.twoStage ? this.knee(side) : null;
if (knee !== null) {
const [kneeDist, kneeZ] = knee;
if (dist <= kneeDist) return startZ + (dist - startDist) / this.cutRatio;
return kneeZ + (dist - kneeDist) / this.soilCutRatio;
}
return startZ + (dist - startDist) / this.cutRatio;
}
/** 짝: `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, startZ] = this.slopeStart(side);
let dist = startDist;
const maxDist = startDist + CROSS_MAX_M;
while (dist <= maxDist) {
const signed = side === "left" ? dist : -dist;
const fillLine = startZ - (dist - startDist) / this.fillRatio;
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 [startDist, startZ] = this.slopeStart(side);
const dist = Math.abs(offsetM);
const run = dist - startDist;
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(startZ - run / this.fillRatio, groundM);
}
/** 짝: `breakpoints`. 적분·설계선에 반드시 넣을 설계 꼭짓점 오프셋. */
breakpoints(): number[] {
const points = [0, this.leftExtent, -this.rightExtent];
for (const [offset] of this.ditchPoints) points.push(offset);
if (this.twoStage) {
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
const knee = role === "cut" ? this.knee(side) : null;
if (knee !== null) points.push(side === "left" ? knee[0] : -knee[0]);
}
}
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;
}
}