Files
Aislo/B06_Section/B06_Section_UI_Cross_Box_Geom.ts
T
eomsangdonandClaude Opus 5 a87182496e feat(B06·B05): BOX암거 조정창 · 구체 기울기 · 3D 벽 구간 정리
조정창에서 좌·우 끝을 따로 잡는다 — ◀▶는 구체 길이, ▲▼는 그 끝의 구체 표고를
0.1m씩 움직인다. 좌·우 표고가 달라지면 구체가 기울고, 날개벽 구간 바닥(에이프런)은
각 끝 표고에서 수평을 지킨다(2026-08-25 사용자).

- `computeBoxLayout`이 좌·우를 따로 푼다: 끝 표고·길이·성토선·에이프런
- 성토선은 그쪽 표고 기준으로 물매 1:1.2를 지키므로 표고를 내리면 구체가 길어진다
- 조정창 `_Cross_Box_Panel.ts` + 제어 `createBoxControls` + `design.box_adjust` 저장
  (세션 → 캐시 → 확정 payload, 세월교와 같은 체계)
- 3D: 구체 밖(날개벽 구간)에 서 있던 벽을 없앴다 — 천장 없는 통로가 생기던 자리는
  날개벽 몫이다. 이제 상판·구체 저판·측벽 2매·에이프런 2매 + 날개벽 4매로 나뉜다
- 카드 렌더러의 구체 배선(선택 토글·강조·조정창)을 `_Cross_View_Bodies.ts`로 분리,
  연동 플래그 세션은 `_Page_Link_Session.ts`로 분리(700줄 제한)

검증(10+0.9): 구체 5.34m·수평 → ◀ 1회 5.44m → ▼ 2회 표고 −0.2m·물매 1:28.4,
에이프런 두 장 모두 수평 유지, ↺ 원복. 3D 솔리드 10개(측벽 링 0.2m).

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

210 lines
9.4 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 교차 참조)가 기본이고, 조정창에서 **좌·우 끝
* 표고를 따로** 올리고 내린다 — 값이 다르면 구체가 기울어진다(물매).
* · **성토선은 그쪽 구체 최상단 모서리에서 끝난다.** 물매 1:1.2 고정이라 구체가 길수록
* 노견이 수평으로 늘어난다(기슭막이 "벽이 밖으로 나간 만큼 노폭 연장"과 같은 규칙).
* · **날개벽 구간 바닥(에이프런)은 각 끝 표고에서 수평**이고 구체 저판만 기울기를
* 따라간다. 에이프런 길이 = 날개벽 투영(길이×cos각).
* · 내공(유로)은 비워 둔다 — 면을 칠하지 않는다.
* 부재 두께는 세월교 승계(측벽 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";
/** 구체 한쪽 끝의 조작값 — 좌·우를 따로 잡는다(2026-08-25 사용자). */
export interface BoxSideAdjust {
/** 기본 길이(성토선 규칙)에서 더 내보낸 양(m, + = 바깥). 음수는 물매가 깨져 막는다. */
lengthM: number;
/** 그쪽 끝의 구체 표고 조정(m, + = 위). 좌·우가 다르면 구체가 기운다. */
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[];
/** 적용된 조작값(한계에 걸린 뒤). */
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;
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 + wanted.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 wing = (role === "left") === inletOnLeft ? 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 });
return {
role,
offset,
topElevation,
bottomElevation,
apron,
fillLine,
adjust: { lengthM, riseM: wanted.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 },
};
}