Files
Aislo/B06_Section/B06_Section_UI_Cross_Box_Geom.ts
T
eomsangdonandClaude Opus 5 55e97b8925 fix(B06): BOX암거 성토선 추가 — 박스 최상단을 지나고 노견은 수평 연장
박스 위 성토선이 빠져 있었다. 물매 1:1.2를 고정하고 성토선이 **박스 최상단
모서리**를 지나게 하면, 박스를 길게 잡을수록 노견이 저절로 수평 연장되고 각도는
그대로다(기슭막이 "벽이 밖으로 나간 만큼 노폭 연장"과 같은 규칙, 2026-08-25 사용자).

- 구체 길이 = 노폭 + 2 × (노면 − 박스 상단) × 1.2. 노면이 크라운이라 좌·우 필요량이
  다르므로 큰 쪽에 맞춰 대칭으로 잡고, 남는 쪽은 노견을 수평 연장한다
- 성토선 = 노견 → (수평 연장) → 박스 최상단 모서리 → 1:1.2로 원지반까지
- 노견 밖 설계선은 이 성토선이 대신하므로 다시 트림한다

검증(10+0.9): 구체 ±2.744m, 저판 8.32m, 좌 성토선 물매 1.200·1.200,
우 성토선 수평 0.144m 뒤 1.200·1.200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 12:52:40 +09:00

168 lines
8.2 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.
/* =============================================================================
* B06_Section_UI_Cross_Box_Geom.ts
* BOX암거 구체 **기하 계산** — 그리기(`_Cross_Box.ts`)와 분리(700줄 제한).
* 백엔드 `section.box` 제원을 좌표로 옮긴다(치수 결정 금지).
*
* 구성(2026-08-25 사용자 확정):
* · 박스 축 = 계류 방향이라 횡단도에는 **종단 절단면**(상판·내공·저판)이 보인다.
* · 상판 윗면 = **노면 − 복토 0.5m**(별표2 교차 참조), 구체는 수평 설치(교본 3장).
* · 저판은 **날개벽 투영**(길이×cos각)만큼 계류 상·하류로 길어진다 — 세월교와 같은 산식.
* · 내공(유로)은 비워 둔다 — 관 위 성토 채움처럼 면을 칠하지 않는다.
* · **성토선은 박스 최상단 모서리를 지난다**(2026-08-25 사용자). 물매 1:1.2 고정이라
* 박스가 노견 밖으로 나간 만큼 노견을 수평 연장하고 거기서 사면을 시작한다 —
* 기슭막이 "벽이 밖으로 나간 만큼 노폭 연장" 규칙과 같다. 그래서 박스 길이를
* 늘리면 노견부가 저절로 길어지고 사면 각도는 그대로다.
* 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다.
* ========================================================================== */
import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
import type { CulvertDesignTrim, OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
export interface BoxLayout {
box: BoxSet;
/** 상판 폴리곤(노견~노견). */
topSlab: OffsetPoint[];
/** 저판 폴리곤 — 날개벽 투영만큼 각 측 연장. */
bottomSlab: OffsetPoint[];
/** 내공(유로) 사각 — 비워 두고 윤곽선·라벨만 쓴다. */
barrel: OffsetPoint[];
/** 상판 윗면·저판 밑면 표고. */
topElevation: number;
bottomElevation: number;
/** 저판 전체 길이(m) — 노폭 + 유입·유출 날개벽 투영. */
slabLengthM: number;
/** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */
bedGapM: number;
/**
* 양측 성토선 — 노견 → (박스가 나간 만큼 수평 연장) → 박스 최상단 모서리 →
* 1:1.2로 원지반까지. 물매는 고정이고 길이만 지형이 정한다.
*/
fillLines: OffsetPoint[][];
label: { at: OffsetPoint; text: string };
/** 노견 밖 설계선은 이 성토선이 대신한다 — 노견에서 끊는다. */
designTrim: CulvertDesignTrim;
}
/** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */
function roadTopFactory(
section: CrossSection,
left: { offset_m: number; elevation_m: number },
right: { offset_m: number; elevation_m: number },
): (offset: number) => number {
const designAt = designInterpolator(section.design?.design_line);
if (designAt) return designAt;
const span = left.offset_m - right.offset_m;
return (offset: number): number => {
if (Math.abs(span) < 1e-6) return left.elevation_m;
const t = (offset - right.offset_m) / span;
return right.elevation_m + (left.elevation_m - right.elevation_m) * t;
};
}
/** 사각 폴리곤(좌상 → 우상 → 우하 → 좌하). */
function rect(left: number, right: number, top: number, bottom: number): OffsetPoint[] {
return [
{ offset: left, elevation: top },
{ offset: right, elevation: top },
{ offset: right, elevation: bottom },
{ offset: left, elevation: bottom },
];
}
/** BOX암거 구체 기하. 제원·설계선·지반이 부족하면 null. */
export function computeBoxLayout(
section: CrossSection,
groundSamples: SectionSample[],
): BoxLayout | null {
const box = section.box;
if (!box) return null;
const edges = section.design?.road_edges;
if (!edges) return null;
const groundAt = groundInterpolator(groundSamples);
if (!groundAt) return null;
// 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백).
const inletOnLeft = (section.uphill_side ?? "left") === "left";
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
const leftEdge = edges.left.offset_m;
const rightEdge = edges.right.offset_m;
if (!(leftEdge > rightEdge)) return null;
// 구체는 수평 설치(교본 3장) — 노면이 가장 낮은 자리에서도 복토를 확보한다.
const roadLow = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0));
const topElevation = roadLow - box.cover_m;
const barrelTop = topElevation - box.top_thickness_m;
const barrelBottom = barrelTop - box.inner_height_m;
const bottomElevation = barrelBottom - box.slab_thickness_m;
// 구체 길이 — 성토선이 박스 최상단을 지나려면 (노면 − 박스 상단) × 물매만큼은 노견
// 밖으로 나가야 한다. 노면이 크라운이라 좌·우 필요량이 다르므로 **큰 쪽에 맞춰**
// 대칭으로 잡고, 남는 쪽은 노견을 수평 연장해 물매 1:1.2를 지킨다(2026-08-25 사용자).
const reach =
Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation > 0
? (Math.max(roadTopAt(leftEdge), roadTopAt(rightEdge)) - topElevation) * FILL_SLOPE_RATIO_MIN
: box.cover_m * FILL_SLOPE_RATIO_MIN;
const bodyLeft = leftEdge + reach;
const bodyRight = rightEdge - reach;
// 저판 연장 — 유입·유출 날개벽 각도가 만든 투영량(백엔드 계산값).
const leftExtend = Math.max((inletOnLeft ? box.wing_in : box.wing_out).slab_extend_m, 0);
const rightExtend = Math.max((inletOnLeft ? box.wing_out : box.wing_in).slab_extend_m, 0);
const slabLeft = bodyLeft + leftExtend;
const slabRight = bodyRight - rightExtend;
// 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다.
let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge));
for (let offset = rightEdge; offset <= leftEdge; offset += 0.25) {
bed = Math.min(bed, groundAt(offset));
}
// 성토선 — 노견 표고에서 수평으로 나간 뒤 박스 최상단 모서리를 지나 지반까지.
const fillLine = (edgeOffset: number, bodyOffset: number, outward: number): OffsetPoint[] => {
const roadZ = roadTopAt(edgeOffset);
// 사면이 박스 모서리를 지나려면 시작점이 모서리에서 (높이차 × 물매)만큼 안쪽이다.
const start = bodyOffset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN;
const corner: OffsetPoint = { offset: bodyOffset, elevation: topElevation };
const points: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }];
// 박스가 더 길면 노견을 수평으로 늘린다(각도 불변 — 2026-08-25 사용자).
if ((start - edgeOffset) * outward > 0.01) points.push({ offset: start, elevation: roadZ });
points.push(corner);
// 모서리 밖은 같은 물매로 원지반과 만날 때까지.
let toe = corner;
for (let drop = 0.05; drop <= 30; drop += 0.05) {
const probe: OffsetPoint = {
offset: corner.offset + outward * drop * FILL_SLOPE_RATIO_MIN,
elevation: corner.elevation - drop,
};
toe = probe;
if (probe.elevation <= groundAt(probe.offset)) break;
}
points.push(toe);
return points;
};
return {
box,
topSlab: rect(bodyLeft, bodyRight, topElevation, barrelTop),
bottomSlab: rect(slabLeft, slabRight, barrelBottom, bottomElevation),
barrel: rect(bodyLeft, bodyRight, barrelTop, barrelBottom),
fillLines: [fillLine(leftEdge, bodyLeft, 1), fillLine(rightEdge, bodyRight, -1)],
designTrim: {
minOffset: rightEdge,
maxOffset: leftEdge,
minElevation: roadTopAt(rightEdge),
maxElevation: roadTopAt(leftEdge),
},
topElevation,
bottomElevation,
slabLengthM: Math.abs(slabLeft - slabRight),
bedGapM: Number((bottomElevation - bed).toFixed(3)),
label: {
at: { offset: (bodyLeft + bodyRight) / 2, elevation: (barrelTop + barrelBottom) / 2 },
text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`,
},
};
}