Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_Body_Guard.ts
|
||
* 세월교 · 박스 유입 · 유출 흙막이(73-4) — 관보호공 흙막이와 **같은 벽 · 같은 자리 규칙**.
|
||
*
|
||
* 세트는 배관 흙막이와 같은 `section.culvert`(관 숨김 · `body`)로 얹혀 그림 · 연동 · 경사 · 3D ·
|
||
* 면적 길을 그대로 탐. 다른 점은 자동 자리 하나 — 벽 **배면(수직)이 본체 끝**에 붙어 벽 몸이 본체
|
||
* 바깥에 섬 · 벽 윗면 = 본체 윗면(세월교 상판 · 박스 상판) · 높이 기본 = 세월교 본체 끝 높이 ·
|
||
* 박스 높이 값(2 m 박스 → 2 m · 79b · 바닥 = 윗면 − 높이)(79-7 · 그림
|
||
* `tmp/79_세월교_기본위치.png`). 좌우 x 는 그 자리 기준 · 상하 d 는 배관과 같은 절대값.
|
||
* 본체가 이 측점에 없으면(연동 옆 측점) 배관 규칙 그대로 — 소유 측점 값을 절대값으로 넘겨 받음.
|
||
* ========================================================================== */
|
||
|
||
import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch";
|
||
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom";
|
||
import type { BoxLayout } from "./B06_Section_UI_Cross_Box_Geom";
|
||
import { FILL_SLOPE_RATIO_MIN } from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import type {
|
||
CulvertDesignTrim,
|
||
CulvertLayout,
|
||
OffsetPoint,
|
||
WallAdjust,
|
||
WallLayout,
|
||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||
import { computeFordLayout, DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford_Geom";
|
||
import type { FordLayout } from "./B06_Section_UI_Cross_Ford_Geom";
|
||
import { buildRevetWallGeometry } from "./B06_Section_UI_Cross_Wall";
|
||
|
||
type Role = "inlet" | "outlet";
|
||
|
||
/** 본체 끝 — 전면 자리(offset) · 윗면(top) · 바닥(bottom) · 관 invert(세월교 바닥판 상면) 표고 ·
|
||
* 흙막이 기본 순수 높이(79-7 · 79b · 80). */
|
||
export interface BodyEnd {
|
||
offset: number;
|
||
top: number;
|
||
bottom: number;
|
||
invert: number;
|
||
height: number;
|
||
}
|
||
|
||
/** 본체 끝(세월교 관 끝 · 박스 끝) + 본체 설계선 트림 — 본체가 이 측점에 없으면 null. */
|
||
export interface BodyGuardBase {
|
||
ends: Record<Role, BodyEnd | null>;
|
||
trim: CulvertDesignTrim;
|
||
}
|
||
|
||
export function bodyGuardBase(
|
||
section: CrossSection,
|
||
samples: SectionSample[],
|
||
): BodyGuardBase | null {
|
||
const body = section.culvert?.body;
|
||
const adjust = section.design;
|
||
if (body === "box" && section.box) {
|
||
const layout = computeBoxLayout(section, samples, {
|
||
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(adjust?.box_adjust?.left ?? {}) },
|
||
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(adjust?.box_adjust?.right ?? {}) },
|
||
});
|
||
if (!layout) return null;
|
||
const inletOnLeft = (section.uphill_side ?? "left") === "left";
|
||
const endOf = (role: Role): BodyEnd | null => {
|
||
const side = layout.sides.find(
|
||
(s) => (s.role === "left") === ((role === "inlet") === inletOnLeft),
|
||
);
|
||
return side
|
||
? {
|
||
offset: side.offset,
|
||
top: side.topElevation,
|
||
bottom: side.bottomElevation,
|
||
invert: side.bottomElevation,
|
||
// 박스 높이 값 그대로(사용자 원문 2 m → 2 m · 79b)
|
||
height: layout.box.inner_height_m,
|
||
}
|
||
: null;
|
||
};
|
||
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
|
||
}
|
||
if (body === "ford" && section.ford) {
|
||
const layout = computeFordLayout(section, samples, {
|
||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.inlet ?? {}) },
|
||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(adjust?.ford_adjust?.outlet ?? {}) },
|
||
});
|
||
if (!layout) return null;
|
||
// 세월교 끝 = 관 끝(측벽 바깥면) · 윗면 = 측벽 상단(상판 높이) · 바닥 = 바닥판 밑
|
||
const endOf = (role: Role): BodyEnd | null => {
|
||
const side = layout.sides.find((s) => s.role === role);
|
||
return side
|
||
? {
|
||
offset: side.pipeEnd.offset,
|
||
top: side.top.elevation,
|
||
bottom: side.slabTopElevation - layout.ford.slab_thickness_m,
|
||
invert: side.slabTopElevation,
|
||
height: side.top.elevation - (side.slabTopElevation - layout.ford.slab_thickness_m),
|
||
}
|
||
: null;
|
||
};
|
||
return { ends: { inlet: endOf("inlet"), outlet: endOf("outlet") }, trim: layout.designTrim };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 흙막이 기본 순수 높이(근입 포함) — 세월교 = 본체 끝 높이 · 박스 = 박스 높이 값(79-7 · 79b). */
|
||
export const bodyGuardHeight = (end: BodyEnd): number => end.height;
|
||
|
||
/**
|
||
* 벽 배면을 본체 끝에 붙인 배관 벽 4축(x · d) — `placePipeWall` 역산.
|
||
* 상단 = 본체 윗면 = 노견 − d/1.2 · 바닥 기준(invert) = 상단 − 높이 · 자리 = 0점 + (x + d).
|
||
*/
|
||
export function bodyGuardAxes(
|
||
end: BodyEnd,
|
||
wall: {
|
||
autoOffset: number;
|
||
outward: number;
|
||
height: number;
|
||
edgeElevation: number;
|
||
lean: number;
|
||
thickness: number;
|
||
},
|
||
): BodyAxes {
|
||
const d = FILL_SLOPE_RATIO_MIN * (wall.edgeElevation - end.top);
|
||
// 배면 위치는 자리(offset)에 평행이동만 함 — 0 에 세워 재고 옮김(노면 멈춤도 같게).
|
||
const probe = buildRevetWallGeometry({
|
||
anchor: { offset: 0, elevation: end.top - wall.height },
|
||
outward: wall.outward,
|
||
height: wall.height,
|
||
material: "dry",
|
||
role: "outlet",
|
||
thickness: wall.thickness,
|
||
lean: wall.lean,
|
||
roadElevation: wall.edgeElevation,
|
||
});
|
||
const anchorOffset = end.offset - probe.bottomBack.offset;
|
||
// 배면 → 관 invert 높이 전면 — 세월교 관이 흙막이 안으로 드는 길이(80)
|
||
const front =
|
||
probe.points[2].offset + wall.outward * probe.lean * (probe.topJoint.elevation - end.invert);
|
||
const through = (front - probe.bottomBack.offset) * wall.outward;
|
||
return {
|
||
x: (anchorOffset - wall.autoOffset) * wall.outward - d,
|
||
d,
|
||
through,
|
||
outward: wall.outward,
|
||
};
|
||
}
|
||
|
||
export type BodyAxes = { x: number; d: number; through: number; outward: number };
|
||
|
||
/** 세월교 관은 1 m 단위(80) — 흙막이 전면까지 든 관 길이를 올림하고 모자란 만큼 흙막이를 바깥으로
|
||
* 미는 기본 x(유출 먼저 · 배관 정수 맞춤과 같은 쪽). 손으로 민 x 는 그 위에 1 m 걸음으로 얹힘. */
|
||
export function fordPipeFit(
|
||
base: BodyGuardBase | null,
|
||
culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } },
|
||
axesOf: (role: Role) => BodyAxes | null,
|
||
): [BodyAxes | null, BodyAxes | null] {
|
||
const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")];
|
||
if (!base || culvert.body !== "ford") return axes;
|
||
const roles = ["inlet", "outlet"] as const;
|
||
const guarded = roles.map((role, i) => (culvert[role].structure ? axes[i] : null));
|
||
const tips = roles.map((role, i) => {
|
||
const end = base.ends[role];
|
||
const reach = guarded[i] ? guarded[i]!.outward * guarded[i]!.through : 0;
|
||
return end && { offset: end.offset + reach, z: end.invert };
|
||
});
|
||
if (!tips[0] || !tips[1]) return axes;
|
||
// ponytail: 관 기울기는 작아 바깥 수평 이동으로 길이를 채움
|
||
const length = Math.hypot(tips[0].offset - tips[1].offset, tips[0].z - tips[1].z);
|
||
const gap = Math.ceil(length - 1e-6) - length;
|
||
const at = guarded[1] ? 1 : 0;
|
||
const fit = guarded[at];
|
||
if (!fit || gap < 1e-6) return axes;
|
||
axes[at] = { ...fit, x: fit.x + gap };
|
||
return axes;
|
||
}
|
||
|
||
/** 벽이 선 쪽은 흙막이 트림 · 안 선 쪽은 본체 트림(73-4). 벽이 하나도 없으면 null. */
|
||
export function mergeGuardTrim(
|
||
guard: CulvertDesignTrim,
|
||
body: CulvertDesignTrim,
|
||
walls: readonly WallLayout[],
|
||
): CulvertDesignTrim | null {
|
||
if (!walls.length) return null;
|
||
const max = walls.some((w) => w.outward > 0) ? guard : body;
|
||
const min = walls.some((w) => w.outward < 0) ? guard : body;
|
||
return {
|
||
minOffset: min.minOffset,
|
||
maxOffset: max.maxOffset,
|
||
minElevation: min.minElevation,
|
||
maxElevation: max.maxElevation,
|
||
minSlope: min.minSlope,
|
||
maxSlope: max.maxSlope,
|
||
};
|
||
}
|
||
|
||
/** 소유 측점 적용값(x = 본체 기준) → 옆 측점이 받을 절대값 — 옆 측점엔 본체가 없음. */
|
||
export function bodyAbsolute(layout: CulvertLayout): CulvertLayout["revetShift"] {
|
||
const shift = layout.revetShift;
|
||
const bodyX = layout.bodyX;
|
||
if (!bodyX) return shift;
|
||
const plus = (adjust: WallAdjust, dx: number): WallAdjust => ({ ...adjust, x: adjust.x + dx });
|
||
return {
|
||
...shift,
|
||
inlet: plus(shift.inlet, bodyX.inlet),
|
||
outlet: plus(shift.outlet, bodyX.outlet),
|
||
};
|
||
}
|
||
|
||
/** 흙막이가 선 쪽의 본체 성토 · 절토선을 걷음 — 그 쪽 선은 흙막이 벽이 냄(두 줄 방지).
|
||
* 세월교는 그 쪽 ㄴ형 측벽도 걷음 — 흙막이가 측벽 자리를 대신함(79-7). */
|
||
export function withoutGuardedFill<T extends BoxLayout | FordLayout>(
|
||
layout: T | null,
|
||
culvert: CulvertLayout | null,
|
||
): T | null {
|
||
if (!layout || !culvert?.culvert.body) return layout;
|
||
const guarded = (outward: number): boolean => culvert.walls.some((w) => w.outward === outward);
|
||
const mid = layout.label.at.offset;
|
||
const reach = (
|
||
points: OffsetPoint[],
|
||
outward: number,
|
||
offset: (p: OffsetPoint) => number,
|
||
): OffsetPoint[] =>
|
||
points.map((p) => ((p.offset - mid) * outward > 0 ? { ...p, offset: offset(p) } : p));
|
||
if ("box" in layout && "topSlab" in layout) {
|
||
const sides = layout.sides.map((side) =>
|
||
guarded(side.role === "left" ? 1 : -1)
|
||
? { ...side, fillLine: [], fillSegments: [], cutLine: null }
|
||
: side,
|
||
);
|
||
// 박스 본체가 흙막이 배면까지 따라감 — 흙막이를 바깥으로 민 만큼만(80)
|
||
let { topSlab, bottomSlab, barrel } = layout;
|
||
for (const side of layout.sides) {
|
||
const outward = side.role === "left" ? 1 : -1;
|
||
const wall = culvert.walls.find((w) => w.role !== "extra" && w.outward === outward);
|
||
const to = wall?.bottomBack.offset ?? side.offset;
|
||
if ((to - side.offset) * outward < 1e-6) continue;
|
||
[topSlab, bottomSlab, barrel] = [topSlab, bottomSlab, barrel].map((p) =>
|
||
reach(p, outward, () => to),
|
||
);
|
||
}
|
||
const run =
|
||
Math.max(...topSlab.map((p) => p.offset)) - Math.min(...topSlab.map((p) => p.offset));
|
||
return { ...layout, sides, topSlab, bottomSlab, barrel, bodyLengthM: run };
|
||
}
|
||
const ford = layout as FordLayout;
|
||
const sides = ford.sides.map((side) =>
|
||
guarded(side.outward) ? { ...side, parts: [], fillSegments: [], cutLine: null } : side,
|
||
);
|
||
// 관 · 바닥판이 흙막이까지 따라감 — 관 끝 꼭짓점 = 그 높이의 흙막이 전면(사면 이용 · 89-1) ·
|
||
// 아래 꼭짓점(invert)은 종전 자리라 1 m 맞춤 그대로 · 바닥판 끝 = 배면(80)
|
||
let { pipe, slabBridge } = ford;
|
||
for (const side of ford.sides) {
|
||
const wall = culvert.walls.find((w) => w.role === side.role && w.outward === side.outward);
|
||
if (!wall) continue;
|
||
const faceAt = (p: OffsetPoint): number =>
|
||
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - p.elevation);
|
||
pipe = {
|
||
outer: reach(pipe.outer, side.outward, faceAt),
|
||
inner: reach(pipe.inner, side.outward, faceAt),
|
||
};
|
||
slabBridge = reach(slabBridge, side.outward, () => wall.bottomBack.offset);
|
||
}
|
||
if (pipe === ford.pipe) return { ...ford, sides } as T;
|
||
const [right, left] = pipe.outer.slice(2);
|
||
const lengthM = Math.hypot(left.offset - right.offset, left.elevation - right.elevation);
|
||
const slabOffsets = [
|
||
...slabBridge,
|
||
...sides.flatMap((s) => s.parts.flatMap((p) => p.points)),
|
||
].map((p) => p.offset);
|
||
return {
|
||
...ford,
|
||
sides,
|
||
pipe,
|
||
slabBridge,
|
||
slabLengthM: Math.max(...slabOffsets) - Math.min(...slabOffsets),
|
||
label: { ...ford.label, text: `${ford.label.text} L${Math.ceil(lengthM - 1e-6)}m` },
|
||
} as T;
|
||
}
|