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:
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user