모서리 밖은 구체 개구부·날개벽이라 성토가 아니다(2026-08-25 사용자). 원지반까지 잇던 구간을 지운다 — 성토선은 노견 → (수평 연장) → 박스 최상단까지만이다. 검증(10+0.9): 좌 [(2, 535.821) → (2.744, 535.201)], 우 [(−2, 535.701) → 수평 → (−2.744, 535.201)] — 두 선 모두 상단 표고에서 종료. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
159 lines
8.0 KiB
TypeScript
159 lines
8.0 KiB
TypeScript
/* =============================================================================
|
||
* 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 });
|
||
// 모서리에서 끝난다 — 그 밖은 구체 개구부·날개벽이라 성토가 아니다
|
||
// (2026-08-25 사용자: 박스와 만난 뒤의 성토선은 필요 없다).
|
||
points.push(corner);
|
||
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)}`,
|
||
},
|
||
};
|
||
}
|