- 박스 흙막이 높이 빈 칸 = 박스 높이 값(2 m 박스 → 2 m) · 윗면 = 본체 윗면 · 바닥 = 윗면 − 높이 · 세월교는 그대로 - x 원점(사면 경사길이 4 m 지점) 되돌림 — 손대지 않은 벽(x · d 빈 칸)만 성토사면 제일 위 · 손으로 x 를 넣은 독립 기슭막이 · 옹벽 · 관 보호공은 종전 자리 - 세월교 · 박스 흙막이 조정창 제목 「흙막이(유입 · 유출)」 · 박스 에이프런 툴팁 유입 · 유출 날개벽 이름 바로잡음 - 시험: test_79_guard_shape — 박스 높이 값 · 저장 x 있는 관 보호공 · 독립 기슭막이 = 종전 자리 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
254 lines
12 KiB
TypeScript
254 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_Box_Geom.ts
|
||
* BOX암거 구체 **기하 계산** — 그리기(`_Cross_Box.ts`)와 분리(700줄 제한).
|
||
* 백엔드 `section.box` 제원을 좌표로 옮긴다(치수 결정 금지).
|
||
*
|
||
* 구성(2026-08-25 사용자 확정):
|
||
* · 박스 축 = 계류 방향이라 횡단도에는 **종단 절단면**(상판·내공·저판)이 보인다.
|
||
* · 상판 윗면 = 노면 − 복토 0.5m(별표2 교차 참조)가 기본이고, 조정창에서 표고를 올리고
|
||
* 내린다 — 본체는 **늘 수평**(79-7 · 좌 · 우 값이 달라도 평균 하나로 섬).
|
||
* · **성토선은 그쪽 구체 최상단 모서리에서 끝난다.** 물매 1:1.2 고정이라 구체가 길수록
|
||
* 노견이 수평으로 늘어난다(기슭막이 "벽이 밖으로 나간 만큼 노폭 연장"과 같은 규칙).
|
||
* · **날개벽 구간 바닥(에이프런)은 각 끝 표고에서 수평**이고 구체 저판만 기울기를
|
||
* 따라간다. 에이프런 길이 = 날개벽 투영(길이×cos각).
|
||
* · 내공(유로)은 비워 둔다 — 면을 칠하지 않는다.
|
||
* · 끝(에이프런 바깥 윗면)이 원지반에 묻히면 절토선, 위로 나오면 성토부선(세월교와 같은 체계 · 79-7).
|
||
* 부재 두께는 세월교 승계(측벽 0.2·상판 0.3·저판 0.3)이며 백엔드가 실어 보낸다.
|
||
* ========================================================================== */
|
||
|
||
import type { BoxSet, CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
|
||
import type {
|
||
BasinLayout,
|
||
CulvertDesignTrim,
|
||
OffsetPoint,
|
||
OutletFillSegment,
|
||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||
|
||
/** 구체 한쪽 끝의 조작값 — 좌·우를 따로 잡는다(2026-08-25 사용자). */
|
||
export interface BoxSideAdjust {
|
||
/** 기본 길이(성토선 규칙)에서 더 내보낸 양(m, + = 바깥). 음수는 물매가 깨져 막는다. */
|
||
lengthM: number;
|
||
/** 구체 표고 조정(m, + = 위) — 본체는 늘 수평이라 좌 · 우 평균 하나로 섬(79-7). */
|
||
riseM: number;
|
||
}
|
||
|
||
/** 측점별 BOX암거 조정값. */
|
||
export interface BoxAdjust {
|
||
left: BoxSideAdjust;
|
||
right: BoxSideAdjust;
|
||
}
|
||
|
||
export type BoxSideRole = "left" | "right";
|
||
|
||
export const DEFAULT_BOX_SIDE_ADJUST: BoxSideAdjust = { lengthM: 0, riseM: 0 };
|
||
export const DEFAULT_BOX_ADJUST: BoxAdjust = {
|
||
left: DEFAULT_BOX_SIDE_ADJUST,
|
||
right: DEFAULT_BOX_SIDE_ADJUST,
|
||
};
|
||
|
||
export interface BoxSideLayout {
|
||
role: BoxSideRole;
|
||
/** 구체 끝 offset(+ = 좌측). */
|
||
offset: number;
|
||
/** 그 끝의 상판 윗면 표고. */
|
||
topElevation: number;
|
||
/** 그 끝의 저판 밑면 표고. */
|
||
bottomElevation: number;
|
||
/** 날개벽 구간 바닥(에이프런) — 이 끝에서 수평으로 뻗는다. 연장이 0이면 빈 배열. */
|
||
apron: OffsetPoint[];
|
||
/** 성토선 — 노견 → (수평 연장) → 구체 최상단 모서리에서 끝. */
|
||
fillLine: OffsetPoint[];
|
||
/** 끝 바깥 성토부선 — 끝이 원지반 위로 나올 때(세월교와 같은 체계 · 79-7). */
|
||
fillSegments: OutletFillSegment[];
|
||
/** 끝이 원지반에 묻힐 때의 절토선(계획선). */
|
||
cutLine: BasinLayout["cutLine"];
|
||
/** 이 끝의 날개벽 칸 — 유입(상단측) · 유출(툴팁 이름 · 79b). */
|
||
wingRole: "inlet" | "outlet";
|
||
/** 적용된 조작값(한계에 걸린 뒤). */
|
||
adjust: BoxSideAdjust;
|
||
}
|
||
|
||
export interface BoxLayout {
|
||
box: BoxSet;
|
||
sides: BoxSideLayout[];
|
||
/** 상판 폴리곤(좌·우 표고가 다르면 기울어진다). */
|
||
topSlab: OffsetPoint[];
|
||
/** 구체 저판 폴리곤 — 상판과 같은 기울기. */
|
||
bottomSlab: OffsetPoint[];
|
||
/** 내공(유로) 사각 — 비워 두고 윤곽선·라벨만 쓴다. */
|
||
barrel: OffsetPoint[];
|
||
/** 구체 길이(m, 좌우 끝 사이). */
|
||
bodyLengthM: number;
|
||
/** 바닥 전체 길이(m) — 구체 + 양측 에이프런. */
|
||
slabLengthM: number;
|
||
/** 구체 물매(1:n). 수평이면 null. */
|
||
slopeRatio: number | null;
|
||
/** 구체 바닥과 계류 하상(원지반 최저)의 고저차(m). +면 구체가 하상 위로 떠 있다. */
|
||
bedGapM: number;
|
||
label: { at: OffsetPoint; text: string };
|
||
/** 노견 밖 설계선은 성토선이 대신한다 — 노견에서 끊는다. */
|
||
designTrim: CulvertDesignTrim;
|
||
/** 한계에 걸린 뒤 **실제 적용된** 조작값 — 조정창이 되받는다. */
|
||
adjust: BoxAdjust;
|
||
}
|
||
|
||
/** 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선(세월교와 같은 규칙). */
|
||
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;
|
||
};
|
||
}
|
||
|
||
/** BOX암거 구체 기하. 제원·설계선·지반이 부족하면 null. */
|
||
export function computeBoxLayout(
|
||
section: CrossSection,
|
||
groundSamples: SectionSample[],
|
||
adjust: BoxAdjust = DEFAULT_BOX_ADJUST,
|
||
): 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;
|
||
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;
|
||
|
||
// 좌표 규약: +offset = 좌측. 유입 = 상단측(미상이면 좌측 폴백).
|
||
const inletOnLeft = (section.uphill_side ?? "left") === "left";
|
||
const baseTop = Math.min(roadTopAt(leftEdge), roadTopAt(rightEdge), roadTopAt(0)) - box.cover_m;
|
||
const bodyHeight = box.top_thickness_m + box.inner_height_m + box.slab_thickness_m;
|
||
// 본체는 늘 수평(79-7) — 좌 · 우 표고 조정이 달라도(옛 저장) 평균 하나로 섬.
|
||
const riseM = (adjust.left.riseM + adjust.right.riseM) / 2;
|
||
const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0);
|
||
const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0;
|
||
|
||
const sideOf = (role: BoxSideRole): BoxSideLayout => {
|
||
const outward = role === "left" ? 1 : -1;
|
||
const edgeOffset = role === "left" ? leftEdge : rightEdge;
|
||
const wanted = role === "left" ? adjust.left : adjust.right;
|
||
const lengthM = Math.max(wanted.lengthM, 0);
|
||
const topElevation = baseTop + riseM;
|
||
// 성토선이 구체 최상단을 지나려면 (노면 − 상단) × 물매만큼은 노견 밖으로 나가야 한다.
|
||
const reach = Math.max((roadTopAt(edgeOffset) - topElevation) * FILL_SLOPE_RATIO_MIN, 0);
|
||
const offset = edgeOffset + outward * (reach + lengthM);
|
||
const bottomElevation = topElevation - bodyHeight;
|
||
// 날개벽 구간 바닥 — 각 끝 표고에서 **수평**(2026-08-25 사용자).
|
||
const wingRole = (role === "left") === inletOnLeft ? "inlet" : "outlet";
|
||
const wing = wingRole === "inlet" ? box.wing_in : box.wing_out;
|
||
const extend = Math.max(wing.slab_extend_m, 0);
|
||
const outer = offset + outward * extend;
|
||
const apron: OffsetPoint[] = extend
|
||
? [
|
||
{ offset, elevation: bottomElevation + box.slab_thickness_m },
|
||
{ offset: outer, elevation: bottomElevation + box.slab_thickness_m },
|
||
{ offset: outer, elevation: bottomElevation },
|
||
{ offset, elevation: bottomElevation },
|
||
]
|
||
: [];
|
||
// 성토선 — 노견 표고에서 수평으로 나간 뒤 구체 최상단 모서리에서 끝난다.
|
||
const roadZ = roadTopAt(edgeOffset);
|
||
const start = offset - outward * (roadZ - topElevation) * FILL_SLOPE_RATIO_MIN;
|
||
const fillLine: OffsetPoint[] = [{ offset: edgeOffset, elevation: roadZ }];
|
||
if ((start - edgeOffset) * outward > 0.01) fillLine.push({ offset: start, elevation: roadZ });
|
||
fillLine.push({ offset, elevation: topElevation });
|
||
// 끝 바깥 절 · 성토선 — 세월교 측벽 바닥판 끝과 같은 규칙(`buildBasin` · 79-7).
|
||
const end: OffsetPoint = { offset: outer, elevation: bottomElevation + box.slab_thickness_m };
|
||
const limitOffset = outward > 0 ? Math.max(...sampleOffsets) : Math.min(...sampleOffsets);
|
||
let cutLine: BasinLayout["cutLine"] = null;
|
||
let fillSegments: OutletFillSegment[] = [];
|
||
if (groundAt(end.offset) > end.elevation + 0.05) {
|
||
for (let h = 0.05; h <= 20; h += 0.05) {
|
||
const probe = end.offset + outward * cutSlopeRatio * h;
|
||
if (groundAt(probe) <= end.elevation + h) {
|
||
cutLine = { from: end, to: { offset: probe, elevation: end.elevation + h } };
|
||
break;
|
||
}
|
||
}
|
||
} else if (groundAt(end.offset) < end.elevation - 0.05) {
|
||
fillSegments = buildExtrasAt(end, {
|
||
startBottomElevation: bottomElevation,
|
||
outward,
|
||
groundAt,
|
||
limitOffset,
|
||
adjusts: [],
|
||
equalize: false,
|
||
}).segments.filter((segment) => segment.kind !== "cut");
|
||
}
|
||
return {
|
||
role,
|
||
offset,
|
||
topElevation,
|
||
bottomElevation,
|
||
apron,
|
||
fillLine,
|
||
fillSegments,
|
||
cutLine,
|
||
wingRole,
|
||
adjust: { lengthM, riseM },
|
||
};
|
||
};
|
||
|
||
const left = sideOf("left");
|
||
const right = sideOf("right");
|
||
const quad = (topDrop: number, bottomDrop: number): OffsetPoint[] => [
|
||
{ offset: left.offset, elevation: left.topElevation - topDrop },
|
||
{ offset: right.offset, elevation: right.topElevation - topDrop },
|
||
{ offset: right.offset, elevation: right.topElevation - bottomDrop },
|
||
{ offset: left.offset, elevation: left.topElevation - bottomDrop },
|
||
];
|
||
const barrelTopDrop = box.top_thickness_m;
|
||
const barrelBottomDrop = barrelTopDrop + box.inner_height_m;
|
||
|
||
// 계류 하상과의 고저차 — 터파기·성토 판정은 후속이라 값만 넘긴다.
|
||
let bed = Math.min(groundAt(leftEdge), groundAt(rightEdge));
|
||
for (let offset = rightEdge; offset <= leftEdge; offset += 0.25) {
|
||
bed = Math.min(bed, groundAt(offset));
|
||
}
|
||
|
||
const rise = left.topElevation - right.topElevation;
|
||
const run = left.offset - right.offset;
|
||
const apronLengthOf = (side: BoxSideLayout): number =>
|
||
side.apron.length ? Math.abs(side.apron[1].offset - side.apron[0].offset) : 0;
|
||
return {
|
||
box,
|
||
sides: [left, right],
|
||
topSlab: quad(0, barrelTopDrop),
|
||
bottomSlab: quad(barrelBottomDrop, barrelBottomDrop + box.slab_thickness_m),
|
||
barrel: quad(barrelTopDrop, barrelBottomDrop),
|
||
bodyLengthM: run,
|
||
slabLengthM: run + apronLengthOf(left) + apronLengthOf(right),
|
||
slopeRatio: Math.abs(rise) > 1e-6 ? Math.abs(run / rise) : null,
|
||
bedGapM: Number((Math.min(left.bottomElevation, right.bottomElevation) - bed).toFixed(3)),
|
||
label: {
|
||
at: {
|
||
offset: (left.offset + right.offset) / 2,
|
||
elevation:
|
||
(left.topElevation + right.topElevation) / 2 - (barrelTopDrop + barrelBottomDrop) / 2,
|
||
},
|
||
text: `BOX ${box.inner_width_m.toFixed(1)}×${box.inner_height_m.toFixed(1)}`,
|
||
},
|
||
designTrim: {
|
||
minOffset: rightEdge,
|
||
maxOffset: leftEdge,
|
||
minElevation: roadTopAt(rightEdge),
|
||
maxElevation: roadTopAt(leftEdge),
|
||
},
|
||
adjust: { left: left.adjust, right: right.adjust },
|
||
};
|
||
}
|