refactor(472-3 B06 벽 · 기슭막이 · 배치): 구조물 기하 logic 분리 · 그림 파일은 그리기만

- logic 새 파일 다섯 — Cross_Wall_Geom(벽 1매) · Cross_Wall_Hatch_Geom(무늬 띠 · 물빼기 · 도수로 띠 · 원지반선) · Cross_Excavation_Geom(벽 터파기) · Cross_Design_Surface_Geom(암선 · 포장층) · Cross_Revetment_Geom(독립 기슭막이 배치)
- logic 으로 옮김 — Body_Guard → Cross_Body_Guard · Structure_Layouts · Route_Carry_Patch(내용 그대로 · import 경로만)
- inferStationInterval → common_util_station(글자표 없는 자리 · common_util_svg 는 다시 내보냄) — Node 묶음 ui_template_locale 끌림 끊음
- 부르는 쪽 import 줄 · 등록부 ts 경로 · 시험 · 도우미 경로
- test_472_3 GEOMETRY_PULLS 다섯 줄 걷음 · Wall_Hatch · Excavation · Design_Surface 세 줄은 Culvert · Box · Ford 그림 파일(518-2)이 그리기 함수로 부르는 동안 남김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 19:22:27 +09:00
co-authored by Claude Opus 5.5
parent 1d5d63875b
commit d3315da993
82 changed files with 1000 additions and 840 deletions
@@ -0,0 +1,302 @@
/* =============================================================================
* B06_Section_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, fordAdjustOf } from "../B06_Section_UI_Cross_Ford_Geom";
import type { FordLayout } from "../B06_Section_UI_Cross_Ford_Geom";
import { buildRevetWallGeometry } from "./B06_Section_Cross_Wall_Geom";
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;
}
/** `defaults` = 세월교 측벽 조작을 안 본 기본 자리(관 1 m 맞춤 기준 · 107-1). */
export function bodyGuardBase(
section: CrossSection,
samples: SectionSample[],
defaults = false,
): 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 stored = defaults ? null : adjust?.ford_adjust;
const layout = computeFordLayout(section, samples, fordAdjustOf(stored));
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 걸음으로 얹힘.
* 맞춤 길이는 **기본 자리**(측벽 조작 0)로만 잼 — 한쪽 이동이 반대쪽 흙막이를 안 밈(107-1 ·
* 수량 L 은 그린 관 길이 올림 그대로). 노견을 넓힌 쪽(139-6)은 사면 우선 — 그 쪽 흙막이는 안 밀고
* 반대쪽(안 넓힌 쪽)을 밂 · 둘 다 넓혔으면 표기만 올림.
* ponytail: 흙막이 높이 역산 없음 — 윗면이 본체 끝 윗면에 묶임(79-7) · 필요하면 높이 역산을 더함. */
export function fordPipeFit(
section: CrossSection,
samples: SectionSample[],
culvert: { body?: string | null; inlet: { structure?: string }; outlet: { structure?: string } },
axesOf: (role: Role, base?: BodyGuardBase | null) => BodyAxes | null,
): [BodyAxes | null, BodyAxes | null] {
const axes: [BodyAxes | null, BodyAxes | null] = [axesOf("inlet"), axesOf("outlet")];
if (culvert.body !== "ford") return axes;
const base = bodyGuardBase(section, samples, true);
if (!base) return axes;
const roles = ["inlet", "outlet"] as const;
const guarded = roles.map((role) => (culvert[role].structure ? axesOf(role, base) : 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 shifted = roles.map((role) => {
const left = (base.ends[role]?.offset ?? 0) > 0;
return (section.design?.[`shoulder_extend_${left ? "left" : "right"}_m`] ?? 0) > 1e-9;
});
const at = [1, 0].find((i) => guarded[i] && !shifted[i]);
if (at === undefined) return axes;
const fit = axes[at];
if (!guarded[at] || !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;
}
/** 세월교 · 박스 유입 흙막이 수평공간 시작 = 본체 바닥(세월교 바닥판 · 박스 저판 상면) 높이의
* 흙막이 전면 자리(89-3). 본체가 이 측점에 없으면 null(배관 규칙). */
export function guardFloorStart(
section: CrossSection,
base: BodyGuardBase | null,
wall: WallLayout,
): OffsetPoint | null {
const end = base?.ends.inlet;
if (!end) return null;
const slab = section.culvert?.body === "box" ? (section.box?.slab_thickness_m ?? 0) : 0;
const floor = end.invert + slab;
return {
offset: wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - floor),
elevation: floor,
};
}
@@ -0,0 +1,71 @@
/* =============================================================================
* B06_Section_Cross_Design_Surface_Geom.ts
* 암선 · 포장층 **기하**(m 좌표) — 그리기는 `../B06_Section_UI_Cross_Design_Surface`(472-3).
* ========================================================================== */
import type { PavementEntry } from "@util/common_util_structure_pavement";
import type { CrossDesign } from "../B06_Section_Api_Fetch";
export type Edge = { offset_m: number; elevation_m: number };
/** 암선 — 유효 지반 샘플을 `offsetM` 만큼 올린 꺾은선들(빈 · 무효 샘플에서 끊음 · 두 점 이상만). */
export function rockBoundarySegments(
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>,
offsetM: number,
): Edge[][] {
const segments: Edge[][] = [];
let current: Edge[] = [];
for (const sample of samples) {
const elevation = sample.elevation_m;
if (sample.valid === false || elevation === null || !Number.isFinite(elevation ?? NaN)) {
if (current.length > 1) segments.push(current);
current = [];
continue;
}
current.push({ offset_m: sample.offset_m ?? 0, elevation_m: (elevation as number) + offsetM });
}
if (current.length > 1) segments.push(current);
return segments;
}
/** 확폭 끈 구간 — 규격 폭 끝(좌 = left − 확폭좌 · 우 = right + 확폭우 · 표고는 두 끝 사이 직선). */
function standardEdges(edges: { left: Edge; right: Edge }, design: CrossDesign) {
const { left, right } = edges;
const span = right.offset_m - left.offset_m || 1;
const at = (offset: number): Edge => ({
offset_m: offset,
elevation_m:
left.elevation_m + ((right.elevation_m - left.elevation_m) * (offset - left.offset_m)) / span,
});
return {
left: at(left.offset_m - (design.widening_left_m ?? 0)),
right: at(right.offset_m + (design.widening_right_m ?? 0)),
};
}
/**
* 포장층 — 노면선(= 포장 윗면) 아래로 두께만큼. 노면공 범위(`section.pavement`)면 그 두께 ·
* 확폭 켬/끔(여러 건은 아래로 차례로 쌓음) · 없으면 카드 포장 켬일 때 표준 두께.
* 층마다 네 꼭짓점(좌상 · 우상 · 우하 · 좌하).
*/
export function pavementLayers(
design: CrossDesign,
pavement?: readonly PavementEntry[] | null,
): Array<{ entry: PavementEntry | null; corners: Edge[] }> {
const edges = design.carriageway_edges ?? design.road_edges;
if (!edges) return [];
const layers = pavement?.length ? pavement : design.paved ? [null] : [];
let depth = 0;
return layers.map((entry) => {
const thickness = entry?.thickness_m ?? design.pavement_thickness_m ?? 0.2;
const { left, right } = entry && !entry.widening_on ? standardEdges(edges, design) : edges;
const corners = [
{ offset_m: left.offset_m, elevation_m: left.elevation_m - depth },
{ offset_m: right.offset_m, elevation_m: right.elevation_m - depth },
{ offset_m: right.offset_m, elevation_m: right.elevation_m - depth - thickness },
{ offset_m: left.offset_m, elevation_m: left.elevation_m - depth - thickness },
];
depth += thickness;
return { entry, corners };
});
}
@@ -0,0 +1,57 @@
/* =============================================================================
* B06_Section_Cross_Excavation_Geom.ts
* 벽 터파기 **기하** — 값은 `common_util_excavation` 한 곳(파이썬 짝 · 거울 시험). 그리기는
* `../B06_Section_UI_Cross_Excavation`(472-3).
* ⚠ **비탈을 주지 않는다 — 수직.** 정본 식에 기울기 항이 없다.
* ========================================================================== */
import {
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
} from "@util/common_util_excavation";
import type { WallLayout } from "../B06_Section_UI_Cross_Culvert_Types";
/** 「기초유」인가 — 저장 제원 `foundation` 의 값. 비어 있으면 **그리지 않는다**(근거 없음). */
export function foundationChoice(value: unknown): "기초유" | "기초버림" | null {
const text = typeof value === "string" ? value.trim() : "";
if (text === "기초유") return "기초유";
if (text === "기초버림") return "기초버림";
return null;
}
/**
* 벽 터파기 — 벽 바닥선 아래로 **수직**. 폭은 바닥 폭 + 여유 0.2, 깊이는 기초 몫.
* `foundation` 이 비어 있으면 null — 사용자가 고르기 전에는 근거가 없다.
*/
export function wallTrench(
wall: WallLayout,
foundation: "기초유" | "기초버림" | null,
): {
widthM: number;
depthM: number;
left: number;
right: number;
top: number;
bottom: number;
} | null {
if (foundation === null) return null;
const hasFoundation = foundation === "기초유";
const depth = hasFoundation ? WALL_FOUNDATION_DEPTH_M : WALL_BLINDING_DEPTH_M;
// 폭: 기초 몫은 정본이 정한 값(0.9 / 0.7), 다만 벽 바닥이 그보다 넓으면 그 폭 + 여유.
const baseWidth = Math.abs(wall.bottomFront.offset - wall.bottomBack.offset);
const minWidth = hasFoundation ? WALL_FOUNDATION_WIDTH_M : WALL_BLINDING_WIDTH_M;
const width = Math.max(minWidth, baseWidth + (hasFoundation ? WALL_TRENCH_CLEARANCE_M : 0));
const center = (wall.bottomFront.offset + wall.bottomBack.offset) / 2;
const top = Math.max(wall.bottomFront.elevation, wall.bottomBack.elevation);
return {
widthM: width,
depthM: depth,
left: center - width / 2,
right: center + width / 2,
top,
bottom: top - depth,
};
}
@@ -0,0 +1,334 @@
/* =============================================================================
* B06_Section_Cross_Revetment_Geom.ts
* 독립 기슭막이(배관과 무관) **기하** — 배관 세트 기슭막이와 **같은 배치·같은 산식·같은 도형**.
* 배관만 없을 뿐, 벽 자리·이동·한계는 배관 유출 벽과 한 몸으로 푼다(2026-08-28 사용자:
* "배관용에서 배관만 빼면 대부분 동일해야"). 그리기는 `../B06_Section_UI_Cross_Revetment`(472-3).
*
* · 자리(2026-08-29 사용자 개정): 조작은 **순수 좌우(x)·순수 상하(d)** 0.1m다.
* x = 자동 자리에서의 수평 이동, d = 노견에서 잰 수직 하강. 성토선 각도는 유지하고
* **노견 확장 구간**이 길이를 흡수한다. 한계는 노견 안쪽 금지·확장 0(각도 유지)·매몰 금지.
* 배관 기준벽(`placePipeWall`)의 이동은 손대지 않는다.
* · 실제 적용값(한계 절삭 후)은 `appliedAdjust`로 돌려주고, 화면이 조작 저장소에 되받는다
* — 눌러도 안 움직이는데 숫자만 커지는 것 방지(배관과 같은 syncApplied 규칙).
* · 다단: 배관과 **같은 함수** `buildExtrasAt`. 노견→벽 성토선은 designTrim의 slope.
*
* 3D 코리도는 `tiers[].polygon`·`span`을 그대로 스윕한다.
* ========================================================================== */
import type { CrossSection } from "../B06_Section_Api_Fetch";
import {
FILL_SLOPE_RATIO_MIN,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_FILL_TOP_M,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { outlineLeanOf } from "./B06_Section_UI_Cross_Lean";
import { catalogRow } from "../../common_util/common_util_structure_catalog";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { buildExtrasAt } from "../B06_Section_UI_Cross_Culvert_Extra";
import {
ZERO_ADJUST,
type CulvertDesignTrim,
type OutletFillSegment,
type WallAdjust,
type WallLayout,
} from "../B06_Section_UI_Cross_Culvert_Types";
import { buildRevetWallGeometry } from "./B06_Section_Cross_Wall_Geom";
/** 서버가 얹어 주는 독립 기슭막이 제원(`section.revetment`, 구조물 정본 D군). */
export interface RevetmentSpec {
structure_id: string | null;
type_id: string;
name: string;
start_m: number;
end_m: number;
anchor_m: number;
form?: string | null;
height_m?: number | null;
/** 사용자가 고른 설치 측 — "좌" | "우". */
side?: string | null;
/** 기초 축 — "기초유" | "기초버림". **비어 있으면 터파기를 안 그린다**(근거 없음). */
foundation?: string | null;
/** 단 수(다단). 1이면 단일 벽. */
tiers?: number | null;
/** 추가 단(2단부터) 형태 · 높이 — 좌측 폼 「추가 단」 상자(178-3). 빈 값 = 본 벽 형태 · 기본 높이. */
extras?: ReadonlyArray<{ form: string | null; height_m: number | null }> | null;
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
lift_m?: number | null;
shift_m?: number | null;
/** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */
face_role?: string | null;
face_slope_ratio?: number | string | null;
}
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
export interface RevetmentAdjust {
x?: number;
d?: number | null;
h?: number | null;
}
export interface RevetPoint {
offset: number;
elevation: number;
}
/** 한 단(段) — 3D 스윕용 폴리곤(배관 벽과 같은 합성 단면). */
export interface RevetmentTier {
polygon: RevetPoint[];
}
export interface RevetmentLayout {
side: "left" | "right";
/** 1단 벽 높이(m) — **바닥~상단**. 조정창 높이 표시·조작의 기준값. */
heightM: number;
/** 이 단면의 누가거리(m) — 부족 안내를 측점 단위로 센다(재렌더 중복 방지). */
chainageM: number;
/** 벽 구조물 id — 그린 벽 신원표(323-5). */
structureId: string | null;
/** 종류 이름(옹벽 · 흙막이 · 돌쌓기 …) — 툴팁 글(323-7 · 「독립 기슭막이」로 뜨던 것). */
name: string;
/** 사용자가 요청한 단 수 — 실제로 선 단 수(`walls.length`)와 다르면 자리가 부족한 것이다. */
requestedTiers: number;
/** 1단 벽 이음선 상단점(성토 설계선 트림 경계). */
top: RevetPoint;
/** 벽 목록(1단 + 다단) — 배관 벽과 같은 `WallLayout`, 그리기가 그대로 쓴다. */
walls: WallLayout[];
/** 기초 축 — 저장 칸과 **같은 글자**(`foundation`). 터파기 그림이 이 값으로 갈린다. */
foundation: string | null;
/** 벽 사이·벽 아래 성토부선(배관 다단과 같은 체계). */
fillSegments: OutletFillSegment[];
/** 성토 설계선을 벽 상단에서 끊고 노견→벽 성토선을 대신 그리는 트림. */
designTrim: CulvertDesignTrim;
/** 한계 절삭 후 **실제 적용된** 1단 조작값 — 화면이 조작 저장소에 되받는다. */
appliedAdjust: WallAdjust;
/** 3D 스윕용 단 목록 — 벽 폴리곤 그대로. */
tiers: RevetmentTier[];
/** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */
span: { beforeM: number; afterM: number };
/** 기준 측점이 아닌 측점(구간 연동) — 주 형상과 색을 갈라 그림(79-6). */
linked: boolean;
}
/** 형태(구조물 옵션) → 재질(높이 한계·라벨). 메 계열은 dry, 찰=wet, 콘크리트=concrete. */
function materialOfForm(form?: string | null): RevetMaterial {
if (!form) return "dry";
if (form.includes("찰")) return "wet";
if (form.includes("콘크리트")) return "concrete";
return "dry"; // 메쌓기·돌망태·통나무·바자 — 메 계열(2.0m 한계)
}
/** 유효 지반 샘플의 바깥 한계 offset(설치 측). 없으면 null. */
function sampleLimit(section: CrossSection, outward: number): number | null {
const offsets = section.samples
.filter((s) => s.valid !== false && typeof s.offset_m === "number")
.map((s) => s.offset_m as number);
if (!offsets.length) return null;
return outward > 0 ? Math.max(...offsets) : Math.min(...offsets);
}
/**
* 노면 횡단물매를 **바깥 방향 1m당 표고 변화**로 돌려준다(하향이면 음수).
* 노면은 측구 쪽으로 내려가는 단일 평면이라(엔진 `road_z`), 노견을 밖으로 연장하면
* 그 평면을 그대로 타고 가야 각도가 맞는다(2026-08-29 사용자).
* 1순위는 **실제 그려진 노견 구간**(차도 끝 → 노면 끝)의 기울기 — 부호를 추측하지
* 않는다. 노견 폭이 0이면 `cross_slope_pct`를 측구 방향 부호로 쓴다.
*/
function roadSlopePerOutward(
design: NonNullable<CrossSection["design"]>,
side: "left" | "right",
outward: number,
): number {
const edge = design.road_edges?.[side];
const inner = design.carriageway_edges?.[side];
if (edge && inner) {
const run = (edge.offset_m - inner.offset_m) * outward;
if (run > 1e-6) return (edge.elevation_m - inner.elevation_m) / run;
}
const pct = Number(design.cross_slope_pct);
if (!Number.isFinite(pct)) return 0;
return (pct / 100) * (design.ditch_side === side ? -1 : 1);
}
/**
* 독립 기슭막이 단면 — 제원·높이·설계선이 없거나 자리를 못 찾으면 null.
* 1단 벽 = 배관 유출 벽과 같은 `placePipeWall`+`buildRevetWallGeometry`,
* 다단 = 배관과 같은 `buildExtrasAt`.
*/
export function computeRevetmentLayout(
section: CrossSection,
adjust?: RevetmentAdjust | null,
): RevetmentLayout | null {
const spec = section.revetment;
const design = section.design;
if (!spec || !design) return null;
const rawHeight = adjust?.h ?? spec.height_m;
// 빈 칸만 안 그림 — 0 이하는 아래 하한(0.1)에서 멈춤(54-51).
if (rawHeight == null || !Number.isFinite(Number(rawHeight))) return null;
const requestedHeight = Number(rawHeight);
// 설치 측이 비어 있으면 **성토가 나는 쪽**에 세운다 — 좌측 「구조물 배치」의 C군 벽
// (옹벽·돌쌓기 등)에는 설치 측 칸이 없기 때문이다(2026-09-06). 양측 절토면 벽이 설
// 자리가 없으므로 그리지 않는다(면적도 그대로).
const fillSide = (): "left" | "right" | null => {
if (design.section_mode === "left_cut") return "right";
if (design.section_mode === "right_cut") return "left";
if (design.section_mode === "both_fill") return "left";
return null;
};
const side = spec.side === "우" ? "right" : spec.side === "좌" ? "left" : fillSide();
if (!side) return null;
const outward = side === "left" ? 1 : -1;
const edge = design.road_edges?.[side];
const groundAt = groundInterpolator(section.samples);
const limit = sampleLimit(section, outward);
if (!edge || !groundAt || limit === null) return null;
// 높이 기준 = **벽 바닥~상단**(근입 0.5 포함 — 배관·추가 벽과 같은 기준).
// 형태별 높이 한계 없음 — 사용자 입력 그대로(하한만 둠).
const pureHeight = Math.max(requestedHeight, WALL_MIN_PURE_HEIGHT_M);
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
const height = pureHeight - REVET_EMBED_DEPTH_M;
// 전면 기울기 — 관 흙막이와 같은 `outlineLeanOf`(형태 무관 · 형태는 안 무늬만). 단면유형 ·
// 설치 측 · 높이 · 사용자 칸은 그대로 탐. 수량(B08)은 자기 칸으로 따로 셈.
const lean = outlineLeanOf({
height_m: requestedHeight,
section_mode: design.section_mode,
side: spec.side,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
const roadSlope = roadSlopePerOutward(design, side, outward);
const autoOffset =
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_FILL_TOP_M);
// x 원점 = 성토사면(1:1.2) 경사길이 4m 지점 — 저장된 x 의 절대 자리 그대로(79b).
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
* 조작 축(2026-08-29 사용자): x = **순수 좌우(m)**, d = **노견에서 잰 순수 수직
* 하강(m)**. 성토선 각도는 그대로 두고 **노견 확장 구간**이 길이를 흡수한다
* (확장량 t는 아래 폴리라인에서 역산 — t ≥ 0이 각도 유지의 한계다).
*/
// 손대지 않은 벽(x · d 빈 칸) = 성토사면 제일 위(노견 · 79-5) · 손으로 넣은 벽은 종전 기본(79b).
const untouched = adjust?.d == null && !adjust?.x;
const defaultDrop = untouched ? 0 : autoRun / FILL_SLOPE_RATIO_MIN;
/** 각도(1:1.2)를 지키려면 그만큼은 밖으로 나가 있어야 하는 최소 수평 자리 — 음수 = 원점보다
* 사면 위(노견 쪽)라 제일 위 자리는 x = −autoRun 이다. */
const xFloor = (drop: number): number => FILL_SLOPE_RATIO_MIN * drop - autoRun;
const requestedX = untouched ? Number.NEGATIVE_INFINITY : (adjust?.x ?? 0);
// 원지반은 자리를 막지 않는다 — 묻히든 뜨든 입력 그대로(54-45). 노면 위 금지 = d ≥ 0.
const appliedDrop = Math.round(Math.max(adjust?.d ?? defaultDrop, 0) * 10) / 10;
// 0.1 눈금 반올림(종전과 같음) · 반올림이 하한 아래로 내리면 하한(제일 위 자리가 노견 안쪽으로 안 감).
const floor = xFloor(appliedDrop);
const appliedX = Math.max(Math.round(Math.max(requestedX, floor) * 10) / 10, floor);
const anchorOffset = autoOffset + outward * (autoRun + appliedX);
const baseElevation = edge.elevation_m - appliedDrop - height;
const floatGapM = Math.max(0, baseElevation - groundAt(anchorOffset));
const material = materialOfForm(spec.form);
const lengthM = Number.isFinite(spec.end_m - spec.start_m) ? spec.end_m - spec.start_m : null;
const tier1 = buildRevetWallGeometry({
anchor: { offset: anchorOffset, elevation: baseElevation },
outward,
height,
material,
role: "outlet",
form: spec.form,
lengthM,
floatGapM,
lean,
roadElevation: edge.elevation_m,
});
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났음.
if (requestedX > 0.005 && floor > requestedX + 0.005) {
tier1.shiftFloorM = Math.round(floor * 100) / 100;
}
// 다단 — 배관과 같은 함수. 1단 벽 전면 하단 꼭짓점을 성토부선 시작점으로.
const requestedTiers = Math.max(1, Math.round(Number(spec.tiers) || 1));
const extras =
requestedTiers > 1
? buildExtrasAt(tier1.bottomFront, {
startBottomElevation: tier1.bottomBack.elevation,
outward,
groundAt,
limitOffset: limit,
// 단마다 상자 형태 · 높이(178-3) — 자리(x · d)는 다단 자동 규칙 그대로
adjusts: Array.from({ length: requestedTiers - 1 }, (_, i) => ({
...ZERO_ADJUST,
m: spec.extras?.[i]?.form ?? null,
h: spec.extras?.[i]?.height_m ?? null,
})),
// 단들도 1단과 같은 형태로 선다 — 안 넘기면 형태가 비어 늘 메쌓기로
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
ownerForm: spec.form,
equalize: false,
leanFor: (form, pureHeightM) =>
outlineLeanOf({
form,
height_m: pureHeightM,
section_mode: design.section_mode,
side: spec.side,
}),
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
const walls: WallLayout[] = [tier1, ...extras.walls];
// 물빼기 파이프 단면 = 표 형상칸.weep(322-1 · 옹벽)
if (catalogRow(spec.type_id)?.shapeOpts.weep) for (const wall of walls) wall.retaining = true;
const jt = tier1.fillTop;
// 노견 → (물매를 탄 노견 연장) → 벽 이음선 상단점. 연장 구간은 노면 물매 그대로 가고,
// 꺾임점에서 성토 물매 1:1.2로 벽 상단에 닿는다(2026-08-29 사용자 확정).
// D = 노견에서 벽 상단까지 바깥 방향 거리, R0 = 노견과 벽 상단의 낙차
// (D − t) = 1.2 × (R0 + roadSlope × t) → t = (D − 1.2·R0) / (1 + 1.2·roadSlope)
const edgePoint = { offset: edge.offset_m, elevation: edge.elevation_m };
const D = (jt.offset - edge.offset_m) * outward;
const R0 = edge.elevation_m - jt.elevation;
const denominator = 1 + FILL_SLOPE_RATIO_MIN * roadSlope;
const breakRun =
R0 > 1e-6 && D > 1e-6 && Math.abs(denominator) > 1e-9
? Math.min(D, Math.max(0, (D - FILL_SLOPE_RATIO_MIN * R0) / denominator))
: 0;
const slope = {
points:
breakRun > 1e-6
? [
edgePoint,
{
offset: edge.offset_m + outward * breakRun,
elevation: edge.elevation_m + roadSlope * breakRun,
},
jt,
]
: [edgePoint, jt],
};
const designTrim: CulvertDesignTrim =
outward > 0
? { minOffset: -Infinity, maxOffset: jt.offset, maxElevation: jt.elevation, maxSlope: slope }
: { minOffset: jt.offset, maxOffset: Infinity, minElevation: jt.elevation, minSlope: slope };
return {
side,
heightM: pureHeight,
chainageM: Number(section.chainage_m) || 0,
structureId: spec.structure_id ?? null,
name: spec.name || "벽",
requestedTiers,
top: jt,
walls,
foundation: typeof spec.foundation === "string" ? spec.foundation.trim() || null : null,
fillSegments: extras.segments,
designTrim,
appliedAdjust: { x: appliedX, d: appliedDrop, h: adjust?.h ?? null, m: null },
tiers: walls.map((wall) => ({ polygon: wall.points })),
span: {
beforeM: Math.max(spec.anchor_m - spec.start_m, 0),
afterM: Math.max(spec.end_m - spec.anchor_m, 0),
},
linked: Math.abs((Number(section.chainage_m) || 0) - spec.anchor_m) > 0.02,
};
}
@@ -0,0 +1,89 @@
/* =============================================================================
* B06_Section_Cross_Wall_Geom.ts
* 기슭막이 벽 1매 **기하** — 배관 세트 벽(`_Cross_Culvert_Geom`/`_Culvert`)과 독립 기슭막이
* (`_Cross_Revetment`)가 같은 산식을 쓴다. 그리기는 `../B06_Section_UI_Cross_Wall`(472-3).
* ========================================================================== */
import {
materialLabel,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
WALL_FILL_TOP_INSET_M,
WALL_MIN_PURE_HEIGHT_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint, WallLayout } from "../B06_Section_UI_Cross_Culvert_Types";
/** 벽 1매의 기하 입력 — 자리 기준은 **하단선 중점**(anchor.offset)과 그 자리 기준표고
* (anchor.elevation = 관 invert 또는 지반). 벽은 그 위로 `height`만큼 선다. */
export interface RevetWallGeometryInput {
/** 하단선 중점 offset + 기준표고(벽이 이 위로 선다). */
anchor: OffsetPoint;
/** +1 = 화면 좌측(계류측), −1 = 우측. 전면 1:0.3이 이 부호로 기운다. */
outward: number;
/** 계산용 높이(기준표고~상단, 근입 0.5 제외). */
height: number;
material: RevetMaterial;
role: "inlet" | "outlet" | "extra";
/** 표기용 형태 — 미지정이면 재질 라벨(메/찰/콘크리트). */
form?: string | null;
lengthM?: number | null;
thickness?: number;
floatGapM?: number;
extraIndex?: number;
/** 전면 기울기 1:n 의 n — 표준경사(`leanOf`). 없으면 종전 0.3. */
lean?: number;
/** 노면(계획고) — 벽 윗면이 이보다 높으면 여기서 멈춤. 높이 한도는 이것 하나. */
roadElevation?: number | null;
}
/**
* 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점을 만든다. 배관 벽(`buildWall`,
* `_Cross_Culvert_Geom` 335~376행)과 **같은 산식** — 배면 수직, 상단 변 = 사다리꼴
* 상단(t/2)+띠(t), 전면 1:0.3, 하단선 = 기준표고 −0.5m 수평 기초.
*/
export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout {
const { anchor, outward, material, role } = input;
const road = input.roadElevation;
const height =
road != null && anchor.elevation + input.height > road + 1e-9
? Math.max(road - anchor.elevation, WALL_MIN_PURE_HEIGHT_M - REVET_EMBED_DEPTH_M)
: input.height;
const thickness = input.thickness ?? REVET_THICKNESS_M;
const lean = input.lean ?? REVET_LEAN_RATIO;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
elevation: bottomElevation,
};
return {
role,
extraIndex: input.extraIndex,
form: input.form ?? materialLabel(material),
lengthM: input.lengthM ?? null,
backOffset,
outerOffset: bottomFront.offset,
base: anchor.elevation,
height,
floatGapM: input.floatGapM ?? 0,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
fillTop: { offset: topFront - outward * WALL_FILL_TOP_INSET_M, elevation: topElevation },
bottomBack,
bottomFront,
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
};
}
@@ -0,0 +1,223 @@
/* =============================================================================
* B06_Section_Cross_Wall_Hatch_Geom.ts
* 벽 안 무늬 · 물빼기 · 도수로 · 돌붙임 띠 **기하**(m 좌표) — 그리기는
* `../B06_Section_UI_Cross_Wall_Hatch`(472-3). 횡단 그림과 B05 3D 띠 솔리드가 같은 것을 쓴다.
* ========================================================================== */
import { REVET_THICKNESS_M } from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint, WallLayout } from "../B06_Section_UI_Cross_Culvert_Types";
/** 형태 문자열 → 표현 갈래. 판정 순서는 좁은 말(돌망태·바자)부터. */
export type WallHatch = "dry" | "wet" | "concrete" | "gabion" | "log" | "wattle";
export function hatchOfForm(form: string | null | undefined): WallHatch {
if (!form) return "dry";
if (form.includes("돌망태")) return "gabion";
if (form.includes("통나무") || form.includes("목재")) return "log";
if (form.includes("바자")) return "wattle";
if (form.includes("콘크리트")) return "concrete";
if (form.includes("찰")) return "wet";
return "dry";
}
/** 켜 수 — 켜 높이가 목표치에 가장 가깝게, 최소 2켜. */
export function courses(spanM: number, targetM: number): number {
return Math.max(2, Math.round(spanM / targetM));
}
/** 벽 전면 기운 띠(이음선~전면, 두께 REVET_THICKNESS_M)의 중심축 — 바닥 · 상단 offset · 바닥 표고 · 길이. */
export function wallHatchBand(wall: WallLayout): {
centerBase: number;
centerTop: number;
embedBase: number;
spanM: number;
} {
// 하단선이 원지반을 따라 기울어 있으므로 띠 바닥도 하단선 위에서 잡는다.
const bottomSpan = wall.bottomFront.offset - wall.bottomBack.offset;
const bottomAtOffset = (offset: number): number =>
Math.abs(bottomSpan) > 1e-9
? wall.bottomBack.elevation +
(wall.bottomFront.elevation - wall.bottomBack.elevation) *
((offset - wall.bottomBack.offset) / bottomSpan)
: wall.bottomBack.elevation;
const centerBase = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
const centerTop = wall.topJoint.offset + wall.outward * (REVET_THICKNESS_M / 2);
const embedBase = bottomAtOffset(centerBase);
const spanM = Math.max(0.3, wall.topBack.elevation - embedBase);
return { centerBase, centerTop, embedBase, spanM };
}
/** 물빼기 파이프 단면(73-2) — 보기용 고정값 · 지식DB 관경 · 간격과 별개. */
const DRAIN_BAND_M = 1.5;
const DRAIN_DIAMETER_M = 0.15;
/** 앞쪽이 낮게 — 벽 두께 1m 당 낙차(보기용). */
const DRAIN_FALL = 0.1;
/** 직선 a→b 가 볼록 벽 단면 안을 지나는 구간 — clip 이 없는 곳(B07 오프스크린 수확)도 벽 밖으로 안 샘. */
function insideRun(a: OffsetPoint, b: OffsetPoint, polygon: OffsetPoint[]): OffsetPoint[] | null {
const dx = b.offset - a.offset;
const dy = b.elevation - a.elevation;
const hits: number[] = [];
polygon.forEach((p, i) => {
const q = polygon[(i + 1) % polygon.length];
const ex = q.offset - p.offset;
const ey = q.elevation - p.elevation;
const denominator = dx * ey - dy * ex;
if (Math.abs(denominator) < 1e-12) return;
const t = ((p.offset - a.offset) * ey - (p.elevation - a.elevation) * ex) / denominator;
const u = ((p.offset - a.offset) * dy - (p.elevation - a.elevation) * dx) / denominator;
if (u >= -1e-9 && u <= 1 + 1e-9) hits.push(t);
});
if (hits.length < 2) return null;
const at = (t: number): OffsetPoint => ({
offset: a.offset + dx * t,
elevation: a.elevation + dy * t,
});
return [at(Math.min(...hits)), at(Math.max(...hits))];
}
/**
* 옹벽 물빼기 — 높이(바닥~상단)를 반올림(높이 / 1.5)개(최소 1) 띠로 고르게 나눠 띠마다
* 가운데 하나. 배면 → 전면으로 꿰는 두 줄 평행선(앞쪽 조금 낮게) · 벽 두께만큼.
* 물빼기마다 벽 안 구간 목록(0~2 줄).
*/
export function wallDrainRuns(wall: WallLayout): OffsetPoint[][][] {
const bottom = wall.bottomBack.elevation;
const height = wall.topBack.elevation - bottom;
const count = Math.max(1, Math.round(height / DRAIN_BAND_M));
const back = wall.backOffset;
const [, , topFront, bottomFront] = wall.points;
const drains: OffsetPoint[][][] = [];
for (let i = 0; i < count; i += 1) {
const center = bottom + (height * (i + 0.5)) / count;
// 그 높이의 전면 — 전면은 기울어 위로 갈수록 배면에 가까움.
const front =
bottomFront.offset + (topFront.offset - bottomFront.offset) * ((center - bottom) / height);
const fall = (DRAIN_FALL * Math.abs(front - back)) / 2;
const runs: OffsetPoint[][] = [];
for (const side of [-0.5, 0.5]) {
const level = center + side * DRAIN_DIAMETER_M;
const run = insideRun(
{ offset: back, elevation: level + fall },
{ offset: front, elevation: level - fall },
wall.points,
);
if (run) runs.push(run);
}
drains.push(runs);
}
return drains;
}
/**
* 도수로 · 돌붙임 띠 단면(90-2) — 윗면 = 사면선(겹친 점 뺌) · 변마다 아래쪽(사면 안쪽) 단위 법선 ·
* 둘레 = 윗면 → 두께만큼 오프셋한 아랫면(거꾸로). 횡단 그림과 B05 3D 띠 솔리드가 같은 것을 쓴다.
*/
export function chuteBandOutline(
raw: OffsetPoint[],
thickness: number,
): { top: OffsetPoint[]; normals: OffsetPoint[]; outline: OffsetPoint[] } | null {
if (!(thickness > 0)) return null;
const top = raw.filter(
(p, i) =>
i === 0 ||
Math.hypot(p.offset - raw[i - 1].offset, p.elevation - raw[i - 1].elevation) > 1e-6,
);
if (top.length < 2) return null;
// 변마다 아래쪽(사면 안쪽) 단위 법선
const normals = top.slice(1).map((p, i) => {
const dx = p.offset - top[i].offset;
const dz = p.elevation - top[i].elevation;
const length = Math.hypot(dx, dz);
const sign = dx / length > 0 ? -1 : 1;
return { offset: (sign * -dz) / length, elevation: (sign * dx) / length };
});
// 꺾임은 두 오프셋 선의 교점(마이터 · 너무 뾰족하면 두 배까지)
const bottom = top.map((p, i) => {
const a = normals[Math.max(i - 1, 0)];
const b = normals[Math.min(i, normals.length - 1)];
const sum = { offset: a.offset + b.offset, elevation: a.elevation + b.elevation };
const k = thickness / Math.max(sum.offset * a.offset + sum.elevation * a.elevation, 0.5);
return { offset: p.offset + sum.offset * k, elevation: p.elevation + sum.elevation * k };
});
return { top, normals, outline: [...top, ...bottom.reverse()] };
}
/** 꺾은선 앞에서부터 `lengthM` 까지(쓴 길이와 함께). */
function trimPolyline(
points: readonly OffsetPoint[],
lengthM: number,
): { points: OffsetPoint[]; used: number } {
const out = points.slice(0, 1);
let used = 0;
for (let i = 1; i < points.length && lengthM - used > 1e-9; i += 1) {
const [a, b] = [points[i - 1], points[i]];
const step = Math.hypot(b.offset - a.offset, b.elevation - a.elevation);
if (step < 1e-9) continue;
const t = Math.min((lengthM - used) / step, 1);
out.push({
offset: a.offset + (b.offset - a.offset) * t,
elevation: a.elevation + (b.elevation - a.elevation) * t,
});
used += step * t;
}
return { points: out, used };
}
/** 원지반 샘플 → 꺾은선(유효 점만 · 오프셋 차례). */
export function groundLine(
samples: ReadonlyArray<{
offset_m?: number | null;
elevation_m?: number | null;
valid?: boolean;
}>,
): OffsetPoint[] {
return samples
.filter((s) => s.valid !== false && s.offset_m != null && s.elevation_m != null)
.map((s) => ({ offset: s.offset_m as number, elevation: s.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
}
/**
* 도수로 · 돌붙임 선 길이(294-4) — 선들을 차례로 사면 따라 `lengthM` 까지 자름 · 사면보다 길면 마지막 끝에서
* 원지반(`ground`)을 바깥으로 따라 이어 그 길이까지 · 길이 없으면 그대로(원지반 닿으면 끝). 점 하나뿐인 선
* (사면 없음 · 끝이 이미 원지반)은 길이가 있으면 원지반 따라 · 없으면 안 그림. 그림 전용(수량 · 내역 무관).
*/
export function chuteRun<L extends { points: OffsetPoint[] }>(
lines: readonly L[],
lengthM?: number | null,
ground?: readonly OffsetPoint[],
): L[] {
const drawn = (list: L[]) => list.filter((line) => line.points.length > 1);
if (!(lengthM && lengthM > 0) || !lines.length) return drawn([...lines]);
let left = lengthM;
const out: L[] = [];
for (const line of lines) {
if (left <= 1e-9) break;
const cut = trimPolyline(line.points, left);
out.push({ ...line, points: cut.points });
left -= cut.used;
}
const last = out[out.length - 1];
if (left > 1e-9 && ground?.length && last) {
const first = lines[0].points[0];
const end = last.points[last.points.length - 1];
const outward = Math.sign(end.offset - first.offset) || Math.sign(end.offset) || 1;
const along = [
end,
...ground
.filter((p) => (p.offset - end.offset) * outward > 1e-6)
.sort((a, b) => (a.offset - b.offset) * outward),
];
out.push({ ...last, points: trimPolyline(along, left).points });
}
return drawn(out);
}
/** 띠가 서는 유출 조각(152-3) — 성토 조각 + 내림 · 수평 절토 조각(3도 절토선 · 끝 ≤ 시작).
* 3도 선이 못 만나 복합경사로 오르는 절토 조각은 뺌. B06 관 · 흙막이 띠와 3D 띠가 같은 거름. */
export function isChuteSegment(segment: { kind?: string; points: OffsetPoint[] }): boolean {
if (segment.kind !== "cut") return true;
const { points } = segment;
return points.length > 1 && points[points.length - 1].elevation <= points[0].elevation + 1e-6;
}
@@ -0,0 +1,434 @@
/* =============================================================================
* B06_Section_Route_Carry_Patch.ts
* 세션 고침 → 상세 설계 칸 얹기 · [저장] 횡단 patch 꼴 **한 벌**(472-6 6-B) — 화면 · 세션 · fetch 없음.
*
* · `layDraftAdjusts` · `layCarriedEdits` — 조정창 · 옮긴 고침 칸(읽는 함수를 받음 · 브라우저 세션 겉 =
* `B06_Section_Section_Adjusts`) · `layPipeOptions` — 관 옵션의 조정값 몫(387-4b).
* · `crossPatchesFromCache` — 캐시 설계의 사용자 칸 = [저장] patch 바탕(445 · 상세 가게가 내줌).
* · `routeCarryPatches` — 노선 바뀐 [저장]: 옛 노선 세션 칸 → 새 노선 서버 상세(측점 열쇠 넘김
* `logic/B06_Section_Route_Carry`) → 얹기 → patch(옛 브라우저 길 `carryAfterRouteSave` 와 같은 차례) ·
* 서버 Node 입구 `stations.carry` 가 부름(`A00_Common/design/logic/design_calc_node_carry.ts`).
* 관 벽 · 단 · 구간 · 세월 · BOX 기본값 = 구조물 기하 파일(`_Cross_Culvert_Types` · `_Wire` · `_Ford_Geom` · `_Box_Geom`).
* ========================================================================== */
import type {
CrossDesign,
CrossSectionPatch,
SectionDetailResponse,
} from "../B06_Section_Api_Fetch";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "../B06_Section_UI_Cross_Culvert_Types";
import type { WallAdjust } from "../B06_Section_UI_Cross_Culvert_Types";
import { fordAdjustOf } from "../B06_Section_UI_Cross_Ford_Geom";
import { DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section_UI_Cross_Box_Geom";
import { PIPE_MATCH_M } from "../../common_util/common_util_pipe_match";
import { inferStationInterval } from "../../common_util/common_util_station";
import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./B06_Section_UI_Cross_Culvert_Const";
import { tierSpanOf } from "../B06_Section_UI_Cross_Culvert_Wire";
import { computeStoredLayouts } from "./B06_Section_Structure_Layouts";
import { ROW_CARRY_KEYS } from "./B06_Section_Station_Swap";
import { mergeCrossPatches } from "./B06_Section_UI_Page_Patches";
import { STRUCTURE_ADJUST_KEYS, USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys";
import {
carryChainageStates,
carryKeyedStates,
readKeyedIn,
stationKeysFrom,
type Keyed,
type KeyedState,
type Owner,
type Raw,
} from "./B06_Section_Route_Carry";
export type Cells = Record<string, unknown>;
/** 누가거리(숫자) → 쪽마다 값(세션에 있는 쪽만 · 노견 확장 · 소단 깊이). */
export type SideValues = ReadonlyMap<number, Partial<Record<"left" | "right", number>>>;
type Obj = Record<string, unknown>;
const INLET = ["auto", "revet", "I", "L", "U"];
const isObj = (value: unknown): value is Obj => !!value && typeof value === "object";
const count = (value: unknown): number =>
Number.isInteger(value) && (value as number) > 0 ? (value as number) : 0;
/** 조정창 읽기와 같은 꼴 — 옛 숫자 = x 이동량 · d 0 = 기본 자리(null). */
function wallAdjust(value: unknown): WallAdjust | null {
if (typeof value === "number" && Number.isFinite(value)) return { ...ZERO_ADJUST, x: value };
if (!isObj(value)) return null;
const partial = value as Partial<WallAdjust>;
return { ...ZERO_ADJUST, ...partial, d: partial.d ? partial.d : null };
}
/** 저장 안 한 조정창 값(누가거리 열쇠 칸 · `read`)을 상세 설계 칸에 얹음 — 조정 4축 · 형식 · 단 수 · 구간 · 연동 ·
* 세월교 · BOX(387-4 · 브라우저 세션 = `withDraftAdjusts` · 서버 = 노선 바뀐 [저장] 넘김 · 472-6). */
export function layDraftAdjusts(
detail: SectionDetailResponse,
read: (name: string) => Cells,
): void {
const revet = read("revetx");
const basin = read("basinadjust");
const inlet = read("inletstruct");
const extra = read("extrawall");
const spans = read("extraspan");
const ford = read("fordadjust");
const box = read("boxadjust");
const link = read("revetlink") as { detached?: Cells; grade?: Cells };
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
const own = (cells: Cells): Array<[string, unknown]> =>
Object.entries(cells)
.filter(([key]) => key.startsWith(`${at}:`))
.map(([key, value]) => [key.slice(at.length + 1), value]);
for (const [role, value] of own(revet)) {
const adjust = wallAdjust(value);
if (adjust) design.revet_adjust = { ...(design.revet_adjust ?? {}), [role]: adjust };
else if (value === null && design.revet_adjust?.[role]) {
// null = 되돌림 — 저장분 그 벽을 뺌(445 M2)
const { [role]: _gone, ...rest } = design.revet_adjust;
design.revet_adjust = rest;
}
}
if (typeof inlet[at] === "string" && INLET.includes(inlet[at] as string))
design.inlet_structure = inlet[at] as CrossDesign["inlet_structure"];
if (isObj(basin[at])) design.basin_adjust = { ...DEFAULT_BASIN_ADJUST, ...basin[at] };
// 세션에 있는 쪽만 — 없는 쪽은 저장분 그대로(445 H3)
const saved = design.extra_wall_counts;
if (at in extra || `b${at}` in extra)
design.extra_wall_counts = {
outlet: at in extra ? count(extra[at]) : (saved?.outlet ?? 0),
basin: `b${at}` in extra ? count(extra[`b${at}`]) : (saved?.basin ?? 0),
};
for (const [wall, value] of own(spans)) {
const span = value as { lengthM: number; beforeM: number; afterM: number } | null;
if (!span || !Number.isFinite(span.beforeM) || !Number.isFinite(span.afterM)) continue;
design.extra_spans = {
...(design.extra_spans ?? {}),
[wall]: { length_m: span.lengthM, before_m: span.beforeM, after_m: span.afterM },
};
}
if (isObj(link.detached) && at in link.detached)
design.revet_link_detached = !!link.detached[at];
if (isObj(link.grade) && at in link.grade) design.revet_follow_grade = !!link.grade[at];
if (isObj(ford[at])) design.ford_adjust = fordAdjustOf(ford[at]);
const sides = box[at] as { left?: Obj; right?: Obj } | undefined;
if (isObj(sides))
design.box_adjust = {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...sides.right },
};
}
}
/** 노선 저장 뒤 새 노선으로 옮긴 세션 고침(417) — 사면 쉬프트 · 소단 깊이 · 측점 폭 · 메모 · 상세 표를 설계 칸에 얹음 ·
* 같은 [저장]의 횡단 패치(`crossPatchesFromCache`)가 실음. 조절 4축 · 형식은 `layDraftAdjusts`(브라우저 세션 =
* `withCarriedEdits`). */
export function layCarriedEdits(
detail: SectionDetailResponse,
read: (name: string) => Cells,
sideValues: { shoulder: SideValues; berm: SideValues },
): void {
const cells: Array<[string, Cells]> = [
["display_half_width_m", read("crossw")],
["structure_note", read("structure-notes")],
["structure_note_cad", read("structure-note-cads")],
["detail_table", read("detail-table")],
];
const sides: Array<[string, SideValues]> = [
["shoulder_extend", sideValues.shoulder],
["berm_depth", sideValues.berm],
];
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
for (const [field, values] of cells) if (values[at] !== undefined) design[field] = values[at];
for (const [field, values] of sides) {
const own = values.get(Number(at));
for (const side of ["left", "right"] as const)
if (own?.[side] !== undefined) design[`${field}_${side}_m`] = own[side];
}
}
}
/**
* 캐시에 얹힌 **사용자 수정분**을 저장 payload로 뽑는다(2026-08-24 사용자 확정 흐름).
*
* 초기 계산값은 원복용으로 서버에 그대로 있고, 사용자가 만진 값은 이 캐시 한 세트가
* 최종본이다. B05 [임시저장]도 이 세트를 함께 보내야 B06에서 만진 구조물 조정이
* 영구저장소에 남는다 — 안 보내면 캐시만 비워져 편집이 사라진다.
*
* 초기 계산값에는 없는 키(사용자가 만져야 생기는 키)를 측점마다 **통째로** 싣는다 — [저장] patch 의 바탕
* (445 H3 · 세션 고침은 그 위에 `mergeCrossPatches`). `savedAt`(서버 행 누가거리)을 주면 그 자리 행이 없는
* 측점(옮긴 관 · 구조물 · 새 측점 = 서버가 기본 설계로 새로 세움)은 지반 등 입력(`ROW_CARRY_KEYS`)도 실음(445 H2).
*/
export function crossPatchesFromCache(
detail: SectionDetailResponse,
kindChanged: readonly number[] = [],
savedAt?: readonly number[],
): CrossSectionPatch[] {
const patches: CrossSectionPatch[] = [];
// 관 종류를 바꾼 측점(318-4) — 옛 종류 조정값은 서버가 비움 · 캐시에 남은 옛 값을 다시 안 실음
const structureKeys: readonly string[] = STRUCTURE_ADJUST_KEYS;
const reset = (at: number) => kindChanged.some((c) => Math.abs(c - at) < PIPE_MATCH_M);
const rowless = (at: number) => !!savedAt && savedAt.every((c) => Math.abs(c - at) >= 0.01);
for (const at of kindChanged) patches.push({ chainage_m: at, structure_reset: true });
for (const section of detail.cross_sections) {
const design = section.design;
if (!design) continue;
const patch: CrossSectionPatch = { chainage_m: section.chainage_m };
const skip = reset(section.chainage_m);
let touched = false;
// 필드를 손으로 나열하지 않는다(2026-09-07) — 목록은 `USER_TOUCHED_KEYS` 한 벌뿐이고
// 서버 목록과 짝이다. 나열이 흩어져 있던 탓에 새 값을 더할 때 한 곳이 빠져 그 값이
// 조용히 사라졌다(`extra_spans` 실사고 `b6941bd2`).
const source = design as unknown as Record<string, unknown>;
const target = patch as unknown as Record<string, unknown>;
const keys = rowless(section.chainage_m)
? [...USER_TOUCHED_KEYS, ...ROW_CARRY_KEYS]
: USER_TOUCHED_KEYS;
for (const key of keys) {
const value = source[key];
if (value === undefined || value === null || (skip && structureKeys.includes(key))) continue;
target[key] = value;
touched = true;
}
if (touched) patches.push(patch);
}
return patches;
}
/** 관 하나 — 자리 · 폼 칸(관 초안 · 저장 관 `pipe_points` 같은 꼴). */
export interface OptionPipe {
chainage_m: number;
options?: Record<string, unknown> | null;
}
const isDefault = (a: WallAdjust): boolean =>
Math.abs(a.x) < 1e-9 && a.d == null && a.h == null && a.m == null;
const round1 = (value: number): number => Math.round(value * 10) / 10;
/** 조정창 `update` 와 같은 꼴 — x · d 는 그대로 · h · m 만(폼 한 칸이 주인 · 빈 칸 = 자동 null). */
function setWall(design: CrossDesign, role: string, h: number | null, m: string | null): void {
const current = { ...ZERO_ADJUST, ...(design.revet_adjust?.[role] ?? {}) } as WallAdjust;
const next: WallAdjust = { ...current, h: h === null ? null : round1(h), m };
const bucket = { ...(design.revet_adjust ?? {}) };
if (isDefault(next)) delete bucket[role];
else bucket[role] = next;
design.revet_adjust = bucket;
}
/** 관 옵션의 조정값 몫을 상세에 얹음(폼 칸 전부 = 되보내기와 같은 규칙) — 같은 값이면 그대로. */
export function layPipeOptions(
detail: SectionDetailResponse,
pipes: ReadonlyArray<OptionPipe>,
): void {
const sections = detail.cross_sections;
for (const pipe of pipes) {
const options = pipe.options;
if (!options) continue;
const near = (at: number): boolean => Math.abs(at - pipe.chainage_m) < PIPE_MATCH_M;
// 물넘이포장 = 제원만(66-2) · 벽 없음
if (sections.some((s) => s.ford_pavement && near(s.ford_pavement.chainage_m ?? s.chainage_m)))
continue;
const owner = sections.find((s) => near(s.chainage_m));
const culvert = owner?.culvert;
const design = owner?.design;
if (!owner || !culvert || !design) continue;
const num = (key: string): number | undefined => {
const value = Number(options[key]);
return Number.isFinite(value) ? value : undefined;
};
for (const role of ["inlet", "outlet"] as const) {
const form = options[`${role}_revet_form`];
setWall(
design,
role,
num(`${role}_revet_height_m`) ?? null,
typeof form === "string" ? form : null,
);
}
if (culvert.hidden_pipe && !owner.ford && !owner.box) continue;
// 흙막이 n 단 = 지형이 세운 유출 다단(`pipeExtraCount` 와 같은 셈)
const outlet = design.extra_wall_counts?.outlet ?? 0;
const probe = {
...owner,
design: { ...design, extra_wall_counts: { basin: 0, ...design.extra_wall_counts, outlet } },
};
const tiers = outlet
? (computeStoredLayouts(probe, sections)?.culvert?.extraWalls.length ?? outlet)
: 0;
for (let n = 1; n <= tiers; n++) {
const role = `extra${n - 1}`;
const value = (item: string): number | undefined => {
const raw = options[`soil_add_${n}_${item}`];
return raw === undefined || raw === "" || !Number.isFinite(Number(raw))
? undefined
: Number(raw);
};
const form = options[`soil_add_${n}_form`];
const height = value("height");
setWall(
design,
role,
height === undefined ? null : Number(Math.max(height, WALL_MIN_PURE_HEIGHT_M).toFixed(1)),
typeof form === "string" && form ? form : null,
);
const span = { lengthM: value("length"), beforeM: value("before"), afterM: value("after") };
if (Object.values(span).every((v) => v === undefined)) continue;
const at = tierSpanOf(owner, role);
const current = {
lengthM: Math.round((at.beforeM + at.afterM) * 10) / 10,
beforeM: Math.round(at.beforeM * 10) / 10,
afterM: Math.round(at.afterM * 10) / 10,
};
const next = applySpanPatch(current, span);
design.extra_spans = {
...(design.extra_spans ?? {}),
[role]: { length_m: next.lengthM, before_m: next.beforeM, after_m: next.afterM },
};
}
}
}
/** 노선 바뀐 [저장] 넘김 입력(브라우저 `routeCarryInputs` + 서버 저장분 · 472-6 6-B). */
export interface RouteCarryInput {
/** 새 노선 서버 상세 — 관 · 구조물 · 추가 측점 · 계획고 차례 뒤(옛 길 `loadSectionDetail` 과 같은 때). */
detail: SectionDetailResponse;
/** 옛 노선 세션 칸 원본 — 측점 열쇠 칸 다섯 · 누가거리 칸 · `kindreset`(상단측 뺌 · 요구 274). */
states: Record<string, unknown>;
/** 옛 노선 측점 열쇠 목록(브라우저가 세운 것 · 없으면 null). */
from_keys: Keyed[] | null;
/** 측점을 덮는 관 · 구조물(측점 계획 · 구조물 목록). */
owners: Owner[];
/** 관 세션 장부(`pipe-ids`). */
pipe_ids: { next: number; ids: Array<{ id: string; at: number }> } | null;
/** 노선 저장 직전 저장 관(id · 누가거리). */
pipes_before: Array<{ id?: string | null; chainage_m: number }>;
/** 관 차례 뒤 저장 관(id · 누가거리 · 옵션). */
pipes_after: Array<OptionPipe & { id?: string | null }>;
}
/** 관 세션 장부 사본 — 찾기 · 없으면 새 차례(브라우저 `pipeIdNear` · `pipeIdAt` · `pipeChainageOf` 와 같은 규칙). */
export function pipeBook(book: RouteCarryInput["pipe_ids"]) {
const ids = [...(book?.ids ?? [])];
let next = book?.next ?? 1;
const near = (at: number): string | null => {
let best: { id: string; at: number } | undefined;
let gap = PIPE_MATCH_M;
for (const entry of ids)
if (Math.abs(entry.at - at) < gap) [best, gap] = [entry, Math.abs(entry.at - at)];
return best?.id ?? null;
};
return {
near,
at: (at: number): string => {
const hit = near(at);
if (hit) return hit;
const id = `pipe-s${next++}`;
ids.push({ id, at });
return id;
},
chainageOf: (id: string): number | null => {
if (!id.startsWith("pipe-")) return null;
const hit = ids.find((entry) => entry.id === id);
if (hit) return hit.at;
const legacy = Number(id.slice("pipe-".length));
return Number.isFinite(legacy) ? legacy : null;
},
};
}
/** 누가거리(0.01 m 글) 열쇠 맵 → 누가거리(숫자) 맵(값이 있는 것만). */
function byChainage<V>(cells: ReadonlyMap<string, V>): Map<number, V> {
const out = new Map<number, V>();
for (const [key, value] of cells) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) out.set(chainage, value);
}
return out;
}
/** 카드 선택 한 벌(세션 `crossdesign` · `CrossDesignChoice` 꼴). */
interface CardChoice {
ground_type?: string;
section_mode?: string;
ditch_side?: string | null;
ditch_type?: string | null;
paved?: boolean;
two_stage_slope?: boolean;
ditch_choice?: boolean | null;
}
type Sides = Partial<Record<"left" | "right", number>>;
/**
* 노선 바뀐 [저장] 횡단 patch — 옛 브라우저 길(`carryAfterRouteSave` → `saveCachedCrossPatches`)과 같은 차례:
* 새 측점 열쇠 → 관 옵션 얹기 → 측점 열쇠 칸 · 누가거리 칸 넘김 → 조정 · 옮긴 고침 얹기 → 캐시 사용자 칸 바탕 +
* 카드 선택 · 암선 · 암 경사. `outside` = 노선 밖으로 버린 고침 수(측점 단위). `detail` 을 제자리에서 고침.
*/
export function routeCarryPatches(input: RouteCarryInput): {
patches: CrossSectionPatch[];
outside: number;
} {
const { detail } = input;
const sections = detail.cross_sections;
const book = pipeBook(input.pipe_ids);
const policy = (
detail.longitudinal?.profile_alignment as
{ policy?: { station_interval_m?: number } } | undefined
)?.policy;
const interval = policy?.station_interval_m ?? inferStationInterval(sections);
const from = input.from_keys ?? undefined;
const to = stationKeysFrom(sections, interval, input.owners, book);
layPipeOptions(detail, input.pipes_after);
const states = input.states;
const keyed = carryKeyedStates((name) => (states[name] as Raw | undefined) ?? {}, from, to);
const newAt = (id: string | null | undefined): number | null =>
input.pipes_after.find((pipe) => id != null && pipe.id === id)?.chainage_m ?? null;
const chain = carryChainageStates(
(name) => (states[name] as Record<string, unknown> | undefined) ?? null,
from,
to,
{
keyAt: (key) => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
return id.startsWith("pipe-") ? book.chainageOf(id) : newAt(id);
},
movedAt: (chainageM) =>
newAt(input.pipes_before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id),
},
);
const cells = (name: string): Cells => chain.states[name] ?? {};
const read = <V>(name: KeyedState) => readKeyedIn<V>(keyed.states[name], to);
layDraftAdjusts(detail, cells);
layCarriedEdits(detail, cells, {
shoulder: byChainage(read<Sides>("shoulderext")),
berm: byChainage(read<Sides>("bermdepth")),
});
const kindChanged = Object.keys(chain.states.kindreset ?? {}).map(Number);
const savedAt = sections.map((section) => section.chainage_m);
const base = crossPatchesFromCache(detail, kindChanged, savedAt);
// 세션에만 있는 고침(카드 선택 · 암선 · 암 경사) — 옛 `sessionOverlay` 와 같은 칸(445 H5)
const overlay: CrossSectionPatch[] = [];
byChainage(read<CardChoice>("crossdesign")).forEach((choice, chainage) => {
const patch: Record<string, unknown> = { chainage_m: chainage };
if (choice.ground_type !== undefined) patch.ground_type = choice.ground_type;
if (choice.section_mode !== undefined) patch.section_mode = choice.section_mode;
if (choice.ditch_side !== undefined) patch.ditch_side = choice.ditch_side;
if (choice.ditch_type !== undefined) patch.ditch_type = choice.ditch_type;
if (choice.paved !== undefined) patch.paved = choice.paved;
if (choice.two_stage_slope !== undefined) patch.two_stage_slope = choice.two_stage_slope;
if (typeof choice.ditch_choice === "boolean") patch.ditch_choice = choice.ditch_choice;
overlay.push(patch as unknown as CrossSectionPatch);
});
for (const [chainage, offset] of read<unknown>("rockb"))
if (typeof offset === "number")
overlay.push({ chainage_m: Number(chainage), rock_boundary_offset_m: offset });
for (const [chainage, ratio] of read<number>("cutslope"))
overlay.push({ chainage_m: Number(chainage), cut_slope_ratio_user: ratio });
const patches = mergeCrossPatches(
base,
overlay.filter((patch) => Number.isFinite(patch.chainage_m)),
);
return { patches, outside: keyed.outside.size + chain.outside.size };
}
@@ -0,0 +1,391 @@
/* =============================================================================
* B06_Section_Structure_Layouts.ts
* 정본(`section.design`)만 읽어 **구조물 기하 한 벌**을 내는 자리 — 화면·조작 없이 돈다.
*
* 왜 있나(2026-09-06, CLAUDE.md 5장 「계산 자리」) — 같은 기하를 세 곳이 쓴다:
* ① B06 횡단 카드(사용자 조작 중) ② B07 도면 작도 ③ 서버 초기 계산(Node 진입점).
* ①은 조작 제어기를 물고 돌아야 하고, ②·③은 저장된 값만 보면 된다. ②가 갖고 있던
* 「제어기 흉내내기」를 여기로 옮겨 ③이 그대로 쓴다 — 기하를 두 벌로 만들지 않는다.
*
* DOM·SVG 를 부르지 않는다. 브라우저에서도 Node 에서도 같은 결과가 나와야 한다.
* ========================================================================== */
import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas";
import type { CrossSection } from "../B06_Section_Api_Fetch";
import { withoutGuardedFill } from "./B06_Section_Cross_Body_Guard";
import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section_UI_Cross_Box_Geom";
import type { BoxLayout } from "../B06_Section_UI_Cross_Box_Geom";
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
import { groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "../B06_Section_UI_Cross_Culvert_Types";
import type { BasinShape, WallAdjust, WallLayout } from "../B06_Section_UI_Cross_Culvert_Types";
import {
computeCardCulvert,
culvertLinkFor,
ownWallsDrawAt,
tierSpanOf,
} from "../B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
RevetOffsetControl,
} from "../B06_Section_UI_Cross_Culvert_Wire";
import { computeFordLayout, fordAdjustOf } from "../B06_Section_UI_Cross_Ford_Geom";
import type { FordLayout } from "../B06_Section_UI_Cross_Ford_Geom";
import { computeRevetmentLayout } from "./B06_Section_Cross_Revetment_Geom";
/** 같은 측점으로 볼 누가거리 오차(m) — 정본 반올림 자릿수보다 크게 잡는다. */
const CHAINAGE_TOLERANCE_M = 0.02;
function storedWallAdjust(section: CrossSection, role: string): WallAdjust {
const stored = section.design?.revet_adjust?.[role];
return stored ? { ...ZERO_ADJUST, ...(stored as Partial<WallAdjust>) } : { ...ZERO_ADJUST };
}
/* 정본만 읽는 조작값 — 편집하지 않으므로 되받기·토스트는 빈 동작이다(B05 평면 횡단 판도 씀 · 393-2). */
export const revetOffset: RevetOffsetControl = {
adjustFor: (section, role) => storedWallAdjust(section, role),
storedAdjustFor: (section, role) =>
section.design?.revet_adjust?.[role] ? storedWallAdjust(section, role) : null,
selectedFor: () => null,
highlightFor: () => null,
select: () => undefined,
syncApplied: () => undefined,
update: () => undefined,
reset: () => undefined,
};
export const extraWalls: ExtraWallControl = {
countFor: (section, side = "outlet") => section.design?.extra_wall_counts?.[side] ?? 0,
setCount: () => undefined,
equalize: () => undefined,
consumeEqualize: () => false,
syncCount: () => undefined,
};
export const inletStructure: InletStructureControl = {
valueFor: (section) => section.design?.inlet_structure ?? "auto",
adjustFor: (section) => ({ ...DEFAULT_BASIN_ADJUST, ...(section.design?.basin_adjust ?? {}) }),
set: () => undefined,
updateAdjust: () => undefined,
resetAdjust: () => undefined,
};
/**
* 그 시설의 **주인 측점** 누가거리 — 시설 자리(`target`)에서 가장 가까운 측점 하나. 자리를 모르면 `null`.
*
* ⚠ 관 자리와 측점 자리는 스냅 때문에 어긋날 수 있다(위 설명 참조). 거리 한계를 두지 않고
* **가장 가까운 하나**만 고르는 것이 요점 — 두 측점이 같이 「주인」이 되면 같은 관을 두 번 센다.
*/
function ownerChainage(
target: number | undefined,
sections: readonly CrossSection[],
): number | null {
if (typeof target !== "number") return null;
let best: number | null = null;
for (const item of sections) {
if (best === null || Math.abs(item.chainage_m - target) < Math.abs(best - target)) {
best = item.chainage_m;
}
}
return best;
}
/** 한 측점의 구조물 기하 한 벌 — 설계선 트림과 구조물 그리기가 같은 결과를 나눠 쓴다. */
export function computeStoredLayouts(section: CrossSection, sections: readonly CrossSection[]) {
const design = section.design;
if (!design) return null;
const designZAt = (chainageM: number): number | null => {
const found = sections.find(
(item) => Math.abs(item.chainage_m - chainageM) <= CHAINAGE_TOLERANCE_M,
);
return found?.design?.design_elevation_m ?? null;
};
const link = section.culvert ? undefined : culvertLinkFor(section, sections, designZAt);
const culvert = computeCardCulvert(
section,
section.samples,
null,
revetOffset,
inletStructure,
extraWalls,
link,
);
// 흙막이가 선 쪽 본체 선 · 세월교 ㄴ형 측벽은 걷음 — B06 카드와 같은 모양(79-7).
const box = withoutGuardedFill(
computeBoxLayout(section, section.samples, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(design.box_adjust?.right ?? {}) },
}),
culvert,
);
const ford = withoutGuardedFill(
computeFordLayout(section, section.samples, fordAdjustOf(design.ford_adjust)),
culvert,
);
// 독립 기슭막이(옛 D군 경로) — 배관 세트가 붙었거나 옆에서 이어져 오면 그쪽이 그린다.
const own = ownWallsDrawAt(section, link?.source)
? computeRevetmentLayout(section, design.revet_adjust?.own)
: null;
return { design, link, culvert, box, ford, own };
}
export type StoredLayouts = NonNullable<ReturnType<typeof computeStoredLayouts>>;
/** 실제로 그려지는 설계선의 트림 — B06 카드와 **같은 우선순위**로 고른다. */
export function trimOfLayouts(layouts: StoredLayouts) {
return (
layouts.culvert?.designTrim ??
layouts.ford?.designTrim ??
layouts.box?.designTrim ??
layouts.own?.designTrim
);
}
/**
* 정본에 얹는 키 — 구조물이 선 측점에서만 나오는 값들.
*
* 면적 넷에 **관 길이**를 더했다(2026-09-08). 관 길이는 브라우저 기하가 **m 단위 올림까지**
* 끝낸 값인데 정본에 없어 **수량이 배수관 연장을 못 냈다**(B08 창 보고). 계산을 서버로
* 옮기거나 새로 짜지 않고, **이미 서버가 Node 로 돌리는 이 다리에 한 줄 더 실었다**
* (CLAUDE.md 5장 — 계산은 한 벌).
*/
export const STRUCTURE_ROW_KEYS = [
"cut_area_m2",
"fill_area_m2",
"cut_soil_area_m2",
"cut_rock_area_m2",
"pipe_length_m",
// B08 하상경사 원천(110) — 관 바닥 두 끝 경사 · 박스 두 끝 원지반 경사(%) · 같은 길로 실음.
"pipe_slope_pct",
"box_ground_slope_pct",
// B08 구조물 집계표 BOX 본체 길이(120) — 박스 두 끝(구체 끝) offset 차 · 같은 길로 실음.
"box_length_m",
// B08 자재 집계표 세월교 관 한 련 길이(131) — 그린 관 길이 1 m 올림 · 같은 길로 실음.
"ford_pipe_length_m",
] as const;
/** 박스 두 끝(구체 끝) 원지반 경사(%) — 유입 − 유출 ÷ 수평거리. 본체는 늘 수평이라 원지반으로(110). */
function boxGroundSlopePct(section: CrossSection, box: BoxLayout): number | null {
const groundAt = groundInterpolator(section.samples);
const inlet = box.sides.find((side) => side.wingRole === "inlet");
const outlet = box.sides.find((side) => side.wingRole === "outlet");
const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0;
if (!groundAt || !inlet || !outlet || !(run > 0)) return null;
return ((groundAt(inlet.offset) - groundAt(outlet.offset)) / run) * 100;
}
/** 박스 본체 길이(m) — 두 끝(구체 끝) offset 차 · 한쪽이라도 없으면 null(120). */
function boxLengthM(box: BoxLayout): number | null {
const inlet = box.sides.find((side) => side.wingRole === "inlet");
const outlet = box.sides.find((side) => side.wingRole === "outlet");
const run = inlet && outlet ? Math.abs(inlet.offset - outlet.offset) : 0;
return run > 0 ? run : null;
}
/** 세월교 관 한 련 길이(m) — 그린 관 아래 두 꼭짓점 사이 1 m 올림(카드 라벨 `L…m` 과 같은 값 · 131). */
function fordPipeLengthM(ford: FordLayout): number | null {
const [right, left] = ford.pipe.outer.slice(2);
if (!right || !left) return null;
const length = Math.hypot(left.offset - right.offset, left.elevation - right.elevation);
return length > 0 ? Math.ceil(length - 1e-6) : null;
}
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
const trim = trimOfLayouts(layouts);
const designLine = layouts.design.design_line;
if (!trim || !Array.isArray(designLine) || designLine.length < 2) return null;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
return {
designLine: designLine as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
};
}
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
function areaRowOf(
section: CrossSection,
sections: readonly CrossSection[],
): Record<string, number> | null {
const layouts = computeStoredLayouts(section, sections);
if (!layouts) return null;
// 관 길이는 면적과 **따로** 낸다 — 폐회로 면적을 못 내는 측점(설계선이 모자란 자리)에도
// 관은 서 있고, 수량은 그 길이를 필요로 한다(2026-09-08).
//
// ⚠ **관을 가진 측점(소유)에만 싣는다.** 옆 측점도 그 관 구간에 걸리면 레이아웃을 만들지만
// (`culvertLinkFor` — 3D·카드가 이어 그리려고), 그 자리에 길이를 실으면 **같은 관을 두 번**
// 세게 된다. 실측에서 관 9개에 값이 10곳 실렸던 자리다.
//
// ⚠⚠ **관 자리와 측점 자리는 최대 0.5m 어긋난다 — 그것이 설계다**(2026-09-09 실측).
// 측점을 만들 때 **정수 미터가 같은 격자 측점이 있으면 그리로 스냅**한다
// (`B05_Profile_Engine_Sections_Core` — 횡단 파일명이 정수 미터라 두 측점이 한 파일을
// 덮어쓰는 것을 막는 가드). 그래서 관 440.241 은 **측점 440.0** 위에 선다.
// ⇒ 0.02m 로 주인을 가리면 **그런 관은 주인이 없어** 길이가 아무 데도 안 실리고,
// B08 이 「연장 없음」으로 막아 **금액이 통째로 빠진다**(실측: 배수관 넷).
// ⇒ **가장 가까운 측점 하나**를 주인으로 본다. 거리로 자르지 않으므로 스냅 폭이
// 바뀌어도 따라가고, 하나만 고르므로 두 번 세지도 않는다.
const isOwner = (target: number | undefined): boolean => {
const owner = ownerChainage(target, sections);
return owner === null || Math.abs(owner - section.chainage_m) <= CHAINAGE_TOLERANCE_M;
};
// 세월교 · 박스 흙막이 세트(73-4)는 관이 아님 — 관 연장을 안 실음.
const pipeOwner =
!!section.culvert && !section.culvert.body && isOwner(section.culvert.chainage_m);
const pipe = pipeOwner ? layouts.culvert?.pipe : undefined;
const ownRow: Record<string, number> = { chainage_m: section.chainage_m };
if (pipe && pipe.lengthM > 0) {
ownRow.pipe_length_m = Number(pipe.lengthM.toFixed(4));
ownRow.pipe_slope_pct = Number(pipe.slopePct.toFixed(4));
}
// 박스도 걸친 옆 측점이 아니라 주인 측점에만(관과 같은 규칙).
const boxSlope =
layouts.box && isOwner(section.box?.chainage_m)
? boxGroundSlopePct(section, layouts.box)
: null;
if (boxSlope !== null) ownRow.box_ground_slope_pct = Number(boxSlope.toFixed(4));
const boxLength =
layouts.box && isOwner(section.box?.chainage_m) ? boxLengthM(layouts.box) : null;
if (boxLength !== null) ownRow.box_length_m = Number(boxLength.toFixed(4));
const fordLength =
layouts.ford && isOwner(section.ford?.chainage_m) ? fordPipeLengthM(layouts.ford) : null;
if (fordLength !== null) ownRow.ford_pipe_length_m = fordLength;
const pipeRow = Object.keys(ownRow).length > 1 ? ownRow : null;
const areaInput = areaInputOf(section, layouts);
if (!areaInput) return pipeRow;
const design = layouts.design;
const areas = computeStructureAreas({
...areaInput,
rockBoundaryOffsetM:
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
});
if (!areas) return pipeRow;
const round = (value: number): number => Number(value.toFixed(4));
const row: Record<string, number> = {
...(pipeRow ?? {}),
chainage_m: section.chainage_m,
cut_area_m2: round(areas.cutAreaM2),
fill_area_m2: round(areas.fillAreaM2),
};
// 토사·암 분리는 원래 값이 있을 때만 덮는다 — 화면 규칙과 같다.
if (typeof design.cut_soil_area_m2 === "number") {
row.cut_soil_area_m2 = round(areas.cutSoilAreaM2);
row.cut_rock_area_m2 = round(areas.cutRockAreaM2);
}
return row;
}
/**
* 구조물이 선 측점의 절·성토 면적 — **카드를 그리지 않은 측점까지** 전부 낸다.
* 다시 셈 창구 · 상세 가게(화면이 보는 면적 · 카드 그리기는 안 얹음 · 387-4)와 [저장]·[확정] · 초기값 산출
* (Node 진입점)이 이 한 벌을 함께 쓴다.
*/
export function structureAreaRows(
sections: readonly CrossSection[],
): Array<Record<string, number>> {
return sections
.map((section) => areaRowOf(section, sections))
.filter((row): row is Record<string, number> => !!row);
}
/** 선 다단 벽 한 매 — B08 이 줄을 세우는 값(파이썬 `B08_Quantity_Engine_Pipe.facility_structures`). */
export interface BuiltExtraWall {
key: string;
side: "outlet" | "basin";
form: string;
/** 순수 높이(m) — 바닥~상단(근입 0.5 포함). 지형에 맞춰 선 값. */
height_m: number;
/** 사용자가 높이·형태를 적었나 — 안 적었으면 B08 이 미확정으로 셈. */
height_set: boolean;
form_set: boolean;
before_m: number;
after_m: number;
/** 벽 몸 중 성토 폐회로 안에 든 넓이(㎡) — 구조물 면적을 안 빼서(2026-09-06 확정) 성토에도 셈.
* B08 이 「두 번 셈」 사유로 올림(2026-09-14 브레인). 면적을 못 내는 측점은 null. */
fill_overlap_m2: number | null;
}
export interface ExtraWallRow {
chainage_m: number;
extra_walls: BuiltExtraWall[];
wall_fill_overlap: Record<string, number>;
/** 실제로 선 유입 집수정 모양(patch 형식 · 자동 지형 규칙 결과) · 안 섰으면 null — B08 관보호공(98-2). */
inlet_basin: BasinShape | null;
}
/**
* 관(·독립 기슭막이) 주인 측점마다 **실제로 선** 다단 벽 목록(④, 2026-09-14).
*
* 단 수·높이는 지형이 정해 요청보다 적게 설 수 있음 — 그래서 요청 칸(`extra_wall_counts`)이 아니라
* 기하가 세운 결과를 남김(관 연장 `pipe_length_m` 과 같은 길). 주인 측점에는 빈 목록도 실어 옛 값을 지움.
* `wall_fill_overlap` — 관 기준벽 역할별 벽 몸 ∩ 성토 폐회로(㎡). B08 기준벽 줄 비고.
*/
export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] {
const rows: ExtraWallRow[] = [];
for (const section of sections) {
if (!section.culvert) continue;
const owner = ownerChainage(section.culvert.chainage_m, sections);
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
const layouts = computeStoredLayouts(section, sections);
const culvert = layouts?.culvert;
if (!layouts || !culvert) continue;
const areaInput = areaInputOf(section, layouts);
const walls: BuiltExtraWall[] = [];
const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [
["outlet", culvert.extraWalls, culvert.revetShift.extras],
["basin", culvert.basinExtras, culvert.revetShift.basinExtras],
];
for (const [side, list, applied] of built) {
list.forEach((wall, index) => {
const key = `${side === "basin" ? "bextra" : "extra"}${index}`;
const span = tierSpanOf(section, key);
walls.push({
key,
side,
form: wall.form ?? "",
height_m: Number((wall.height + REVET_EMBED_DEPTH_M).toFixed(3)),
height_set: applied[index]?.h != null,
form_set: section.design?.revet_adjust?.[key]?.m != null,
before_m: span.beforeM,
after_m: span.afterM,
fill_overlap_m2: areaInput
? Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3))
: null,
});
});
}
// 관 기준벽(유입·유출)도 같은 겹침 — 다단에만 사유가 뜨면 「다단만 문제」로 읽힘(㉡ 2026-09-14).
const wall_fill_overlap: Record<string, number> = {};
for (const wall of culvert.walls) {
if (areaInput && (wall.role === "inlet" || wall.role === "outlet")) {
wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3));
}
}
rows.push({
chainage_m: section.chainage_m,
extra_walls: walls,
wall_fill_overlap,
inlet_basin: culvert.basin?.shape ?? null,
});
}
return rows;
}
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
export function applyStructureAreaRows(
sections: readonly CrossSection[],
rows: ReadonlyArray<Record<string, number>>,
): void {
for (const row of rows) {
const design = sections.find((item) => item.chainage_m === row.chainage_m)?.design as
Record<string, unknown> | undefined;
if (!design) continue;
for (const key of STRUCTURE_ROW_KEYS) {
if (typeof row[key] === "number") design[key] = row[key];
}
}
}