Files
Aislo/B06_Section/B06_Section_UI_Cross_Box_Geom.ts
T
eomsangdonandClaude Opus 5.5 77e8aa2709 fix(B06): 박스 흙막이 기본 높이 = 박스 높이 값 · 저장 x 벽 종전 자리 그대로 · 흙막이 조정창 제목 · 박스 날개벽 툴팁(79b)
- 박스 흙막이 높이 빈 칸 = 박스 높이 값(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
2026-10-08 00:26:10 +09:00

254 lines
12 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 교차 참조)가 기본이고, 조정창에서 표고를 올리고
* 내린다 — 본체는 **늘 수평**(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 },
};
}