feat(b06): 물넘이 낮은 쪽 노면 끝이 절토면 콘크리트 · 노면선을 노면 기울기로 늘여 원지반까지 (294-3)

- 설계 계산(파이썬 _SectionGeometry · TS 짝 SectionGeometry) — 기울인 자리(tilted) 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면 같은 기울기로 늘여 원지반과 처음 만나는 자리까지 · 그 쪽 절토 사면 없음 · 샘플 반폭 안에서 못 만나면 옛 길 · 성토 쪽 그대로 · 결과 tilt_reach
- 토공 면적 · 설계선이 늘인 노면을 따름(서버 정본 · [저장] 재계산 · 거울 시험 갈래)
- 횡단 그림 — 콘크리트(포장층) · 월류 높이선이 늘인 끝까지(fordEdges)
- 시험 test_294_3 새로 · 횡단 설계 거울 「물넘이포장 낮은 쪽 노면 연장」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-12 21:47:54 +09:00
co-authored by Claude Opus 5.5
parent b87c55c846
commit 7b3bb72b54
9 changed files with 306 additions and 1 deletions
+14
View File
@@ -194,6 +194,8 @@ export interface CrossDesignResult {
/** 물넘이포장 · 맹암거 자리(286-2 · 287-4) — 측구 없음 · 지대 낮은 쪽 한 경사. */
tilted?: boolean;
tilt_pct?: number;
/** 물넘이 낮은 쪽 늘인 노면 끝(294-3) — 그 쪽 절토 사면 대신 노면이 원지반까지. */
tilt_reach?: { side: "left" | "right"; offset_m: number; elevation_m: number };
pavement_thickness_m?: number;
rock_boundary_offset_m?: number;
}
@@ -377,6 +379,7 @@ export function computeCrossDesign(
shoulderExtendLeftM: options.shoulderExtendLeftM ?? 0,
shoulderExtendRightM: options.shoulderExtendRightM ?? 0,
tilted: Boolean(options.tilted),
groundSpan: [valid[0][0], valid[valid.length - 1][0]],
});
// 적분 오프셋 = 지반 샘플 ∪ 설계 꼭짓점(샘플 범위 안쪽만).
@@ -592,6 +595,17 @@ export function computeCrossDesign(
cut_slope_segments: geometry.cutSlopeSegments(),
};
if (options.tilted) result.tilted = true;
// 낮은 쪽 늘인 노면 끝(294-3 · 파이썬 짝)
for (const side of ["left", "right"] as const) {
const reach = geometry.reach[side];
if (!options.tilted || reach === undefined) continue;
const signed = side === "left" ? reach : -reach;
result.tilt_reach = {
side,
offset_m: round4(signed),
elevation_m: round4(geometry.roadZ(signed)),
};
}
if (options.tilted && options.tiltPct != null) result.tilt_pct = round4(options.tiltPct);
if (drop > 0) result.surface_drop_m = round4(drop);
if (options.berm) {
@@ -147,6 +147,8 @@ export class SectionGeometry {
shoulderExtendRightM?: number;
/** 물넘이포장 · 맹암거 자리 — 측구 없음 · 노면 = 지대 낮은 쪽 한 경사(286-2 · 287-4 · 파이썬 짝 `tilted`). */
tilted?: boolean;
/** 원지반 샘플 범위(오프셋 최소 · 최대) — 낮은 쪽 연장 한계(294-3 · 파이썬 짝 `ground_span`). */
groundSpan?: [number, number] | null;
}) {
const { group } = params;
const halfRoad = group.road_width_m / 2;
@@ -261,6 +263,36 @@ export class SectionGeometry {
];
}
}
// 물넘이 낮은 쪽 연장(294-3) — 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면(절토) 노면을
// 같은 기울기로 늘여 원지반과 처음 만나는 자리까지(그 쪽 절토 사면 없음) · 반폭 안에서 못 만나면
// 옛 길(절토 사면). ⚠ 파이썬 짝 `_extend_low_side`.
if (params.tilted && params.groundAt && Math.abs(this.slopePerOffset) > 1e-12)
this.extendLowSide(params.groundAt, params.groundSpan ?? null);
}
/** 낮은 쪽 늘인 노면 끝(오프셋 절대값) — 쪽마다(294-3). */
reach: { left?: number; right?: number } = {};
private extendLowSide(groundAt: (offsetM: number) => number, span: [number, number] | null) {
const side = this.slopePerOffset < 0 ? "left" : "right";
const sign = side === "left" ? 1 : -1;
const extent = side === "left" ? this.leftExtent : this.rightExtent;
const limit = span ? (side === "left" ? span[1] : -span[0]) : extent + 50;
const gap = (dist: number) => groundAt(sign * dist) - this.roadZ(sign * dist); // > 0 = 절토
if (gap(extent) <= 1e-3) return;
const step = 0.05;
let dist = extent;
while (dist < limit - 1e-9) {
const next = Math.min(dist + step, limit);
const g0 = gap(dist);
const g1 = gap(next);
if (g1 <= 0) {
this.reach[side] = dist + ((next - dist) * g0) / (g0 - g1);
return;
}
dist = next;
}
}
/** 노면(노견 포함) 설계고 — 중심 계획고에서 횡단경사로 기운 단일 평면. */
@@ -422,6 +454,8 @@ export class SectionGeometry {
}
const role = side === "left" ? this.leftRole : this.rightRole;
const dist = Math.abs(offsetM);
const reach = this.reach[side];
if (reach !== undefined) return dist <= reach + 1e-9 ? this.roadZ(offsetM) : groundM; // 294-3
if (role === "cut") {
const cross = this.cutCrossDist(side);
if (cross !== null && dist >= cross) return groundM;
@@ -512,6 +546,9 @@ export class SectionGeometry {
}
}
}
// 늘인 노면 끝(294-3)
if (this.reach.left !== undefined) points.push(this.reach.left);
if (this.reach.right !== undefined) points.push(-this.reach.right);
for (const side of ["left", "right"]) {
const role = side === "left" ? this.leftRole : this.rightRole;
const cross = role === "cut" ? this.cutCrossDist(side) : this.fillCrossDist(side);