조정창에서 좌·우 끝을 따로 잡는다 — ◀▶는 구체 길이, ▲▼는 그 끝의 구체 표고를 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>
210 lines
9.4 KiB
TypeScript
210 lines
9.4 KiB
TypeScript
/* =============================================================================
|
||
* 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 },
|
||
};
|
||
}
|