Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts
T
eomsangdonandClaude Opus 5 38acdd02ed feat(B06): 기슭막이 자리·하단선 규칙 재정립
2026-08-21 사용자 확정:
- 기본 자리 = **성토 사면이 원지반과 만나는 지점**(사면 끝). 사면이 5m를 넘으면
  기슭막이가 사면을 5m에서 끊는다 — 5m는 자리 한계가 아니라 "5m 이상이면 기슭막이
  의무"라는 규정(성토_비탈면.md §2)의 표현이다. 물매 1:1.2~2.0으로 자리를 풀던
  방식은 걷어냈다(자유도 축소였다).
- 벽 하단선 = **원지반을 수직 0.5m 내린 선**. 수평 바닥이라 사면에서 앞 모서리가
  뜨던 문제가 사라진다(배면·전면 모두 근입 0.500 확인).
- 자리 기준(이동 중심) = **하단선 중점**. 배면 기준이면 벽이 기울 때 체감 위치가
  어긋난다.
- 관 유출단 invert = 그 자리 원지반 → 관 하단 경사가 지반과 만난다.
- structureRequired = 사면길이 5m 이상(의무 발생), 종전의 "받을 수 없는 자리" 판정 대체.

그리기도 기운 하단선에 맞췄다 — 이음선 바닥·돌 해칭이 하단선 위에서 잡힌다.

검증: 두 측점 이동 10케이스 근입 배면·전면 모두 0.500, 하단선 중점이 원지반과 일치
(관 수평 클램프 2건 제외), 계획고 스윕 24케이스 문제 0. tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 17:56:35 +09:00

665 lines
33 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Culvert_Geom.ts
* 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
* 백엔드가 만든 `section.culvert` 제원을 좌표로 옮긴다 — 치수를 새로 정하지 않는다.
*
* 배치 규칙 — 실무 횡단도 4장 기준(2026-08-20 사용자 제공, 울진 계열 주기):
* · 기슭막이 2기 — 유입/유출측 성토사면 안. 사면 쪽으로 기운 평행사변형(전면 1:0.3,
* 두께 0.45m). 자리는 사면선이 이음선 상단점을 지나도록 풀어서 정한다.
* · 배관 — 두 벽 사이(도로 하부)만 직선. 끝단면은 벽 전면과 평행하게 마감(수평 가능).
* · 보호공(돌붙임) — 관 하단 꼭짓점에서 성토사면 경사를 따라 원지반까지, 최소 길이
* (낙차고×2)를 채울 때까지 지반을 따라 더 간다. 유입측은 수평 보호공 없음.
* · 성토 경사 — 벽 전면과 교차된 이후는 끊는다(designTrim — 벽·보호공이 대신한다).
*
* 유입 = 상단측(uphill_side), 유출 = 하단측. 유입구가 "집수정"이면 그쪽 기슭막이는
* 그리지 않고 집수정 단면(I/ㄴ/ㄷ형)으로 바꾼다.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
FILL_MIN_RISE_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
MIN_PIPE_COVER_M,
PITCHING_THICKNESS_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type {
BasinLayout,
BasinShape,
CulvertLayout,
EndFace,
OffsetPoint,
PipeEnd,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
// 상수·자료형은 분리 파일에 있고, 기존 import 경로를 유지하기 위해 그대로 다시 내보낸다.
export * from "./B06_Section_UI_Cross_Culvert_Const";
export * from "./B06_Section_UI_Cross_Culvert_Types";
import type { FillSlopeSegment } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
clampWallOffset,
cutLength,
outletSlopeFactory,
fillSlopeOf,
groundInterpolator,
designInterpolator,
intersect,
slopeToeOffset,
STRAY_LIMIT_M,
} from "./B06_Section_UI_Cross_Culvert_Solve";
/** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */
export function computeCulvertLayout(
section: CrossSection,
groundSamples: SectionSample[],
/** 사용자가 손으로 민 기슭막이 X 이동량(m, + = 계류측 바깥). 없으면 자동 자리. */
revetShift?: { inlet?: number; outlet?: number },
): CulvertLayout | null {
const culvert = section.culvert;
if (!culvert) return null;
const groundAt = groundInterpolator(groundSamples);
if (!groundAt) return null;
const edges = section.design?.road_edges;
if (!edges) return null;
const designAt = designInterpolator(section.design?.design_line);
const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0);
const minSample = Math.min(...sampleOffsets);
const maxSample = Math.max(...sampleOffsets);
if (!(maxSample > minSample)) return null;
// 좌표 규약: +offset = 좌측(화면 왼쪽). 상단측이 유입이다(미상이면 좌측 폴백).
const uphill = section.uphill_side ?? "left";
const sideInfo = (side: "left" | "right") => ({
edge: side === "left" ? edges.left : edges.right,
outward: side === "left" ? 1 : -1,
limit: side === "left" ? maxSample : minSample,
});
const inletInfo = sideInfo(uphill === "left" ? "left" : "right");
const outletInfo = sideInfo(uphill === "left" ? "right" : "left");
// 유입 invert: 유입 노견 아래 원지반. 절토측이면 "노면 − 관경 − 토피"가 상한
// (관 상단 + 토피가 노면 안에 들어가는 최고 자리 — B05 하향 차단식과 동일).
const diameter = culvert.diameter_m;
const invertCap = (edge: { offset_m: number; elevation_m: number }): number =>
(designAt ? designAt(edge.offset_m) : edge.elevation_m) - diameter - MIN_PIPE_COVER_M;
const inletInvert = Math.min(groundAt(inletInfo.edge.offset_m), invertCap(inletInfo.edge));
const inlet: OffsetPoint = { offset: inletInfo.edge.offset_m, elevation: inletInvert };
// 유입측 절토 판정: 절토측 유입은 자연스럽게 집수정(ㄴ형 기본)이 된다.
const mode = section.design?.section_mode;
const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right";
const inletIsCut =
mode === "both_cut" ||
(mode === "left_cut" && inletSideName === "left") ||
(mode === "right_cut" && inletSideName === "right");
// 성토측 유입이라도 관 앞 원지반이 관 단면의 30% 이상을 막으면 집수정으로 바꾼다
// (2026-08-20 사용자 확정 — 자연 유입이 불가해 옆도랑 물을 받아야 하는 형상).
const inletFrontOffset =
inlet.offset + inletInfo.outward * (REVET_THICKNESS_M * 2 + REVET_LEAN_RATIO * diameter);
const blockedRatio = (groundAt(inletFrontOffset) - inletInvert) / Math.max(diameter, 1e-6);
const inletBlocked = !inletIsCut && blockedRatio >= 0.3;
const basinReason: BasinLayout["reason"] | null = inletIsCut
? "cut"
: inletBlocked
? "blocked"
: null;
// 성토측 유입 기슭막이는 노견에 붙여 두지 않는다 — 사면선이 벽 이음선 상단점을 지나야
// 하므로 자리를 풀어서 정한다(집수정은 측구부 자리 그대로).
const wallHeightFor = (spec: CulvertSideSpec): number =>
Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
/** 요청값이 한계에 걸려 잘린 뒤의 **실제** 이동량. 조정창이 이 값을 되받는다. */
const appliedShift = { inlet: 0, outlet: 0 };
// 기슭막이 기본 자리 = **성토 사면이 원지반과 만나는 지점**(사면 끝)이고, 그 자리가
// 하단선 중점이 된다(2026-08-21 사용자 확정). 사면길이 5m는 자리 한계가 아니라
// "5m 이상이면 기슭막이 의무"라는 뜻이라 여기서 자르지 않는다.
let inletAutoOffset: number | null = null;
if (!basinReason && designAt) {
inletAutoOffset = slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit);
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
const shifted = clampWallOffset(
inletAutoOffset,
inletAutoOffset + inletInfo.outward * (revetShift?.inlet ?? 0),
wallHeightFor(culvert.inlet),
inletInfo.edge,
inletBaseAt,
inletInfo.outward,
);
appliedShift.inlet = Math.round((shifted - inletAutoOffset) * inletInfo.outward * 10) / 10;
inlet.offset = shifted;
inlet.elevation = inletBaseAt(shifted);
}
// 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2).
// 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면).
const outletAnchorOffset = designAt
? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit)
: outletInfo.edge.offset_m;
// 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①).
let fillSlopeLength = 0;
if (designAt) {
const from = outletInfo.edge.offset_m;
const steps = 60;
let prevElev = designAt(from);
for (let i = 1; i <= steps; i += 1) {
const o = from + ((outletAnchorOffset - from) * i) / steps;
const e = designAt(o);
fillSlopeLength += Math.hypot((outletAnchorOffset - from) / steps, e - prevElev);
prevElev = e;
}
}
// 역경사는 수평으로 클램프(수평 가능 — 사용자 확정).
const outletInvert = Math.min(groundAt(outletAnchorOffset), inlet.elevation);
const outletAnchor: OffsetPoint = { offset: outletAnchorOffset, elevation: outletInvert };
const run0 = outletAnchor.offset - inlet.offset;
const rise0 = outletAnchor.elevation - inlet.elevation;
const length0 = Math.hypot(run0, rise0);
if (!(length0 > 0.5)) return null;
// 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(2026-08-20 사용자 확정).
let lengthM = Math.ceil(length0 - 1e-6);
const scale = lengthM / length0;
const outlet: OffsetPoint = {
offset: inlet.offset + run0 * scale,
elevation: inlet.elevation + rise0 * scale,
};
const span = Math.abs(outletAnchor.offset - inlet.offset);
let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0;
// 기슭막이 합성 단면(사용자 ①·②): 하부 = 배면 수직·전면 1:0.3 **사다리꼴**(관이
// 지나는 관경 높이), 상부 = 전·배면이 나란히 기운 **평행사변형**. 상단 배면 꼭짓점이
// 성토 사면선(설계선)과 만나는 접점이 되도록 높이를 정한다 — 사면선은 거기서 끊긴다.
const walls: WallLayout[] = [];
let basin: BasinLayout | null = null;
/** 집수정이 서면 관 유입단을 내공 안으로 옮긴다(접속 표현). */
let basinPipeEnd: OffsetPoint | null = null;
/** 관 끝단 마감면 — 구조물 계류측 변 복사(2026-08-20 사용자 ③). */
const endFaces: { inlet: EndFace | null; outlet: EndFace | null } = {
inlet: null,
outlet: null,
};
let trimMin = Number.NEGATIVE_INFINITY;
let trimMax = Number.POSITIVE_INFINITY;
// 트림 경계에서 성토 경사선 끝단이 **닿아야 할 표고** — 벽 높이를 관경+여유고로 고정한
// 뒤로는 설계 사면선이 벽 상단과 어긋나므로, 사면선 끝을 이 점까지 내려 맞춘다
// (2026-08-21 사용자 ② — 평행사변형·사다리꼴 교차 두 점 중 **상단** 교차점).
let trimMinElevation: number | null = null;
let trimMaxElevation: number | null = null;
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도). 설계선 대신 그린다. */
let trimMinSlope: { from: OffsetPoint; to: OffsetPoint } | null = null;
let trimMaxSlope: { from: OffsetPoint; to: OffsetPoint } | null = null;
const buildWall = (
spec: CulvertSideSpec,
anchor: OffsetPoint,
outward: number,
forceBasinReason: BasinLayout["reason"] | null,
thickness: number = REVET_THICKNESS_M,
): WallLayout | null => {
const reason: BasinLayout["reason"] | null =
forceBasinReason ?? (spec.structure === "집수정" ? "cut" : null);
if (reason) {
// 집수정 = 기슭막이와 같은 **평행사변형 벽**(+ 형식에 따라 바닥·반대측 막음).
// 형식 기본값 = ㄴ(L)형(2026-08-20 사용자 확정). 실무 내공 1.0m(울진 돌집수정).
const shape: BasinShape = "L";
const innerWidth = 1.0;
const floorThickness = REVET_THICKNESS_M;
const roadTop = designAt ? designAt(anchor.offset) : anchor.elevation;
// 벽 높이: **상단 도로측 꼭지점이 성토 경사선(설계선)과 만나는 교점**까지
// (2026-08-20 사용자 — 기슭막이와 같은 접점 규칙). 하한은 관경 + 0.45.
const basinMinHeight = diameter + REVET_THICKNESS_M;
let wallHeight = Math.max(roadTop - anchor.elevation, basinMinHeight);
if (designAt) {
const wallBaseProbe = anchor.offset + outward * innerWidth;
for (let h = basinMinHeight; h <= 4.0 + 1e-6; h += 0.05) {
// 상단 도로측 꼭지점(기움 반전 — 상단이 도로측으로 물러난다).
const topInner = wallBaseProbe - outward * REVET_LEAN_RATIO * h;
wallHeight = h;
if (anchor.elevation + h >= designAt(topInner)) break;
}
wallHeight = Math.max(wallHeight, basinMinHeight);
}
// 벽 단면은 기슭막이와 동일(두께 0.45). 기움은 **수직 기준 반전** — 상단이
// 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20 사용자 정정).
const wallOf = (baseOffset: number, dir: number): OffsetPoint[] => {
const topShift = -dir * REVET_LEAN_RATIO * wallHeight;
return [
{ offset: baseOffset, elevation: anchor.elevation - floorThickness },
{ offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight },
{
offset: baseOffset + topShift + dir * REVET_THICKNESS_M,
elevation: anchor.elevation + wallHeight,
},
{
offset: baseOffset + dir * REVET_THICKNESS_M,
elevation: anchor.elevation - floorThickness,
},
];
};
// I형 벽 = 계류측(도로 바깥) 면 — 관 유입단에서 내공 1.0m 떨어져 선다.
const wallBase = anchor.offset + outward * innerWidth;
const parts: BasinLayout["parts"] = [{ kind: "wall", points: wallOf(wallBase, outward) }];
// ㄴ형 = I형 + 바닥판. 바닥은 **아래가 짧은 사다리꼴**(윗변이 길고 아랫변이
// 짧다 — 벽과 같은 1:0.3 물매가 양 끝에 붙는다). 위치는 **I형 벽 기준 반대측**
// = 도로측으로 뻗는다(2026-08-20 사용자 확정).
// 바닥은 **I형 벽의 바깥(계류측) 변에 좌측 변을 맞대고** 그 너머로 뻗는다
// (2026-08-20 사용자 확정). 벽이 기울어 있으므로 바닥 좌측 변도 **벽 바깥면 선을
// 그대로 따라** 기울여야 틈 없이 붙는다(높이마다 벽 x가 달라진다).
const wallOuterAt = (elevation: number): number => {
const bottom = anchor.elevation - floorThickness;
const t = (elevation - bottom) / (wallHeight + floorThickness);
return wallBase + outward * REVET_THICKNESS_M - outward * REVET_LEAN_RATIO * wallHeight * t;
};
const floorTopInner = wallOuterAt(anchor.elevation);
const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness);
const floorOuter = floorTopInner + outward * (innerWidth + REVET_THICKNESS_M);
const floorTaper = REVET_LEAN_RATIO * floorThickness;
parts.push({
kind: "floor",
points: [
{ offset: floorTopInner, elevation: anchor.elevation },
{ offset: floorOuter, elevation: anchor.elevation },
{
offset: floorOuter - outward * floorTaper,
elevation: anchor.elevation - floorThickness,
},
{ offset: floorBottomInner, elevation: anchor.elevation - floorThickness },
],
});
// ㄷ형이면 반대측(도로측) 막음벽을 하나 더 세운다 — 지금은 L형 기본이라 미사용.
if ((shape as BasinShape) === "U") {
parts.push({ kind: "wall", points: wallOf(anchor.offset, -outward) });
}
// ㄴ·ㄷ형이 원지반 **안쪽**에 박히면 그만큼 절토가 필요하다(2026-08-20 사용자 ①).
// 구조물 바깥 끝 상단에서 표준단면 절토경사(1:n)로 원지반과 만나는 점까지 긋는다.
let basinCut: BasinLayout["cutLine"] = null;
if ((shape as BasinShape) !== "I") {
const outerTop = { offset: floorOuter, elevation: anchor.elevation };
if (groundAt(floorOuter) > anchor.elevation + 0.05) {
const cutRatio = section.design?.cut_slope_ratio ?? 1.0;
for (let h = 0.05; h <= 20; h += 0.05) {
const probe = floorOuter + outward * cutRatio * h;
if (groundAt(probe) <= anchor.elevation + h) {
basinCut = {
from: outerTop,
to: { offset: probe, elevation: anchor.elevation + h },
};
break;
}
}
}
}
basin = {
shape,
parts,
// 라벨 자리 = **I형 벽 하단**(2026-08-20 사용자). 상단에 두면 노면·계획선과
// 겹친다 — 벽 바닥 변 중앙에 두고 그리기 쪽에서 아래로 내려 붙인다.
label: {
offset: wallBase + outward * (REVET_THICKNESS_M / 2),
elevation: anchor.elevation - floorThickness,
},
reason,
cutLine: basinCut,
};
// 성토 사면선은 **벽 상단 도로측 꼭지점(교점)**에서 끊는다 — 바닥 끝을 기준으로
// 두면 사면선이 집수정을 뚫고 들어간다(2026-08-20 사용자 ①).
const wallTopInner = wallBase - outward * REVET_LEAN_RATIO * wallHeight;
if (outward > 0) {
trimMax = wallTopInner;
trimMaxElevation = anchor.elevation + wallHeight;
} else {
trimMin = wallTopInner;
trimMinElevation = anchor.elevation + wallHeight;
}
// 관 유입 끝단 마감면 = 집수정 벽의 **계류측 변**을 그대로 복사(사용자 ③).
const faceBottom = {
offset: wallBase + outward * REVET_THICKNESS_M,
elevation: anchor.elevation - floorThickness,
};
const faceTop = {
offset: wallBase - outward * REVET_LEAN_RATIO * wallHeight + outward * REVET_THICKNESS_M,
elevation: anchor.elevation + wallHeight,
};
endFaces[spec.role] = {
base: faceBottom,
direction: {
offset: faceTop.offset - faceBottom.offset,
elevation: faceTop.elevation - faceBottom.elevation,
},
};
// 관은 집수정 내공을 가로질러 **안쪽 벽면까지** 물린다 — 벽 몸통 중간에서 끊기면
// 접속이 어긋나 보인다(2026-08-20 사용자 ③).
basinPipeEnd = {
offset: anchor.offset + outward * innerWidth,
elevation: anchor.elevation,
};
return null;
}
// 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면
// (계류측)은 1:0.3 기운 평행사변형 띠. 띠 안쪽 평행선과 수직 배면 사이가 사다리꼴로,
// 바닥이 넓고 상단이 좁다(상단 폭 = 띠 두께 0.45).
// 높이: **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). 관이
// 물매를 두어 좌·우 invert가 달라도 **관 상단 위 여유고는 같아야** 한다. 사면선은
// 높이를 정하지 않고 벽이 설 **자리**만 정한다(`solveFillWallOffset`).
// 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다.
const height = Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
// **자리 기준 = 하단선 중점**(2026-08-21 사용자 확정). 배면 기준으로 잡으면 벽이
// 기울 때 체감 위치가 어긋난다. anchor.offset이 하단선 중점, anchor.elevation이
// 그 자리 원지반(= 관 invert)이다.
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const frontBase = anchor.offset + outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
// 상단 변: 사다리꼴 상단(t/2) + 띠(t). 이음선은 상단 변 중간점에서 1:0.3으로 바닥까지.
const topFront = topJoint + outward * thickness;
// 근입: 하단선은 **원지반을 수직으로 0.5m 내린 선**이다(2026-08-21 사용자 확정).
// 종전에는 수평 바닥이라 사면에서 앞 모서리가 떴다.
const bottomAt = (offset: number): number => groundAt(offset) - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomAt(backOffset) };
const bottomFront: OffsetPoint = { offset: frontBase, elevation: bottomAt(frontBase) };
const wall: WallLayout = {
role: spec.role,
form: spec.revet_form ?? null,
lengthM: spec.revet_length_m ?? null,
backOffset,
outerOffset: frontBase,
base: anchor.elevation,
height,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
bottomBack,
bottomFront,
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
};
walls.push(wall);
// 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ②).
if (outward > 0) {
trimMax = topJoint;
trimMaxElevation = topElevation;
} else {
trimMin = topJoint;
trimMinElevation = topElevation;
}
// 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③).
endFaces[spec.role] = {
base: bottomFront,
direction: {
offset: topFront - bottomFront.offset,
elevation: topElevation - bottomFront.elevation,
},
};
return wall;
};
buildWall(culvert.inlet, inlet, inletInfo.outward, basinReason);
// 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는
// 1:1.2~2.0 범위에서 역산).
//
// 벽 밑 = 그 자리의 **원지반**. 종전처럼 관 축(inlet → 사면 끝 직선) 위에 얹으면
// 성토측 지반이 관 물매보다 가파른 구간에서 축이 지반 위로 떠올라 **벽이 뜬다**
// (2026-08-21 사용자 지적). 관 끝은 원지반 위라는 원칙대로 지반을 따르면 벽이
// 지반에 앉고 관 물매가 그 자리에 맞춰진다. 역경사는 유입 invert로 클램프.
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
// 유출 벽 기본 자리도 **사면 끝**(= outletAnchorOffset)이고 그 자리가 하단선 중점이다.
let outletWallAnchor = outletAnchor;
const outletAutoOffset = outletAnchorOffset;
if (designAt) {
const shifted = clampWallOffset(
outletAutoOffset,
outletAutoOffset + outletInfo.outward * (revetShift?.outlet ?? 0),
wallHeightFor(culvert.outlet),
outletInfo.edge,
invertAt,
outletInfo.outward,
);
appliedShift.outlet = Math.round((shifted - outletAutoOffset) * outletInfo.outward * 10) / 10;
outletWallAnchor = { offset: shifted, elevation: invertAt(shifted) };
}
let outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null);
// 유출 벽이 사면 끝보다 안쪽으로 당겨졌으면 **관 끝도 그 자리에 맞춘다** — 관만 사면
// 끝까지 남으면 벽과 떨어져 마감면 교차가 실패한다(2026-08-20 사용자 ② 원인).
if (outletWall && Math.abs(outletWallAnchor.offset - outletAnchor.offset) > 0.05) {
const runW = outletWallAnchor.offset - inlet.offset;
const riseW = outletWallAnchor.elevation - inlet.elevation;
const lenW = Math.hypot(runW, riseW);
if (lenW > 0.5) {
const scaleW = Math.ceil(lenW - 1e-6) / lenW;
outlet.offset = inlet.offset + runW * scaleW;
outlet.elevation = inlet.elevation + riseW * scaleW;
lengthM = Math.ceil(lenW - 1e-6);
}
}
// ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변에 맞춰 자른다(2026-08-20 사용자 ③).
// 보호공 시작점이 **관 하단 꼭짓점**을 그대로 써야 하므로 여기서 확정한다.
const pipeStart = basinPipeEnd ?? inlet;
const runP = outlet.offset - pipeStart.offset;
const riseP = outlet.elevation - pipeStart.elevation;
const axisLength = Math.hypot(runP, riseP) || 1;
const axis: OffsetPoint = { offset: runP / axisLength, elevation: riseP / axisLength };
let normal: OffsetPoint = { offset: -axis.elevation, elevation: axis.offset };
if (normal.elevation < 0) normal = { offset: -normal.offset, elevation: -normal.elevation };
const topAnchor: OffsetPoint = {
offset: pipeStart.offset + normal.offset * diameter,
elevation: pipeStart.elevation + normal.elevation * diameter,
};
const endCorners = (role: "inlet" | "outlet", endPoint: OffsetPoint): PipeEnd => {
const fallback: PipeEnd = {
bottom: endPoint,
top: {
offset: endPoint.offset + normal.offset * diameter,
elevation: endPoint.elevation + normal.elevation * diameter,
},
};
const face = endFaces[role];
if (!face) return fallback;
const bottom = intersect(face.base, face.direction, pipeStart, axis);
const top = intersect(face.base, face.direction, topAnchor, axis);
if (!bottom || !top) return fallback;
const strayed =
Math.hypot(bottom.offset - endPoint.offset, bottom.elevation - endPoint.elevation) >
STRAY_LIMIT_M ||
Math.hypot(top.offset - fallback.top.offset, top.elevation - fallback.top.elevation) >
STRAY_LIMIT_M;
return strayed ? fallback : { bottom, top };
};
let pipeCorners = {
inlet: endCorners("inlet", pipeStart),
outlet: endCorners("outlet", outlet),
};
// ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①). 관 끝은 기운 벽 전면으로 잘려 **상단·하단
// 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리**
// 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과
// 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②).
const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, wallHeightFor(culvert.outlet));
/** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */
const outletThickness = REVET_THICKNESS_M;
if (outletWall) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
const rebuild = (): void => {
walls.pop();
outletWall = buildWall(
culvert.outlet,
outletWallAnchor,
outletInfo.outward,
null,
outletThickness,
);
outlet.offset = pipeStart.offset + axis.offset * target;
outlet.elevation = pipeStart.elevation + axis.elevation * target;
pipeCorners = {
inlet: endCorners("inlet", pipeStart),
outlet: endCorners("outlet", outlet),
};
};
for (let pass = 0; pass < 8; pass += 1) {
const gap = target - cutLength(pipeCorners);
if (Math.abs(gap) < 0.005) break;
// ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위).
const moved = outletWallAnchor.offset + axis.offset * gap;
const movedSlope = outletSlopeAt(moved, outletThickness);
// 사면 역전·노견 안쪽으로는 옮기지 않는다 — 길이 맞춤보다 도면 성립이 먼저다.
if (
(moved - outletInfo.edge.offset_m) * outletInfo.outward < 0 ||
outletInfo.edge.elevation_m - (invertAt(moved) + wallHeightFor(culvert.outlet)) <
FILL_MIN_RISE_M
) {
break;
}
// 조정창으로 옮긴 벽은 **폭을 건드리지 않는다** — 폭으로 흡수하면 미는 동안 벽
// 단면이 변한다(2026-08-21 사용자 지적: 유출 벽만 0.45→0.90). 조정이 관 길이 1m
// 단위라 자리만 맞추면 정수가 성립한다. 물매·5m 한계는 자동 배치용 제약이라
// 사용자가 지정한 자리에는 걸지 않는다(경고로 알린다).
if (
Math.abs(revetShift?.outlet ?? 0) > 1e-9 ||
(movedSlope.ratio >= FILL_SLOPE_RATIO_MIN &&
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6)
) {
outletWallAnchor = { offset: moved, elevation: invertAt(moved) };
rebuild();
if (!outletWall) break;
continue;
}
// ② 더 옮기면 물매가 범위를 벗어나거나 사면이 5m를 넘는다 — 여기서 멈춘다.
// **폭으로 흡수하지 않는다**: 폭을 건드리면 좌·우 벽 단면이 서로 달라지고
// (2026-08-21 사용자 지적 — 유입을 밀었더니 유출 벽이 0.45→0.85로 부풀었다),
// 남는 몫은 관이 벽을 조금 벗어나는 것으로 두는 게 기존 확정 규칙이다.
break;
}
// 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다).
lengthM = Math.max(1, Math.ceil(cutLength(pipeCorners) - 1e-6));
slopePct =
Math.abs(outlet.offset - pipeStart.offset) > 1e-9
? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) *
100
: 0;
}
// 보호공(돌붙임) — 유출측. 2026-08-20 사용자 ①·②:
// ① 시작 변은 **기슭막이 전면과 같은 경사**(밑면을 벽 전면 방향으로 민다),
// 윗면 시작점 = **관 하단 꼭짓점**(관 밑면과 한 점에서 만난다).
// ② 성토사면 경사(1:n)로 원지반까지 내려간 뒤에도 **최소 보호공 길이**
// (낙차고×2 — 백엔드 `apron_length_m`)를 채울 때까지 원지반을 따라 더 간다.
// 끝단 윗점이 원지반 위에 놓인다.
const pitchingTop: OffsetPoint[] = [];
const pitchingBase: OffsetPoint[] = [];
let pitchingLength = 0;
const minPitchingM = culvert.outlet.apron_length_m ?? 0;
if (outletWall) {
const fillRatio = section.design?.fill_slope_ratio ?? 1.2;
// 밑면 이동 방향 = 기슭막이 전면 변과 나란한 **아래 방향** 단위벡터 × 두께.
let shift: OffsetPoint = { offset: 0, elevation: -PITCHING_THICKNESS_M };
const face = endFaces.outlet;
if (face) {
const faceLength = Math.hypot(face.direction.offset, face.direction.elevation);
if (faceLength > 1e-9) {
shift = {
offset: (-face.direction.offset / faceLength) * PITCHING_THICKNESS_M,
elevation: (-face.direction.elevation / faceLength) * PITCHING_THICKNESS_M,
};
}
}
const start = pipeCorners.outlet.bottom;
pitchingTop.push(start);
let previous = start;
const step = 0.25;
for (let t = step; t <= 30 + 1e-9; t += step) {
const offset = start.offset + outletWall.outward * t;
const slopeElevation = start.elevation - t / fillRatio;
const groundElevation = groundAt(offset);
const point: OffsetPoint = {
offset,
elevation: Math.max(slopeElevation, groundElevation),
};
pitchingLength += Math.hypot(
point.offset - previous.offset,
point.elevation - previous.elevation,
);
pitchingTop.push(point);
previous = point;
// 지반에 닿았고 최소 길이도 채웠으면 마감 — 아직이면 지반을 따라 계속 간다.
if (slopeElevation <= groundElevation && pitchingLength >= minPitchingM - 1e-6) break;
}
for (const point of pitchingTop) {
pitchingBase.push({
offset: point.offset + shift.offset,
elevation: point.elevation + shift.elevation,
});
}
}
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
const slopeOf = (wall: WallLayout): FillSlopeSegment =>
fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge);
for (const wall of walls) {
const slope = slopeOf(wall);
// 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의
// 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다).
if (wall.outward > 0) {
trimMax = slope.from.offset;
trimMaxElevation = null;
trimMaxSlope = { from: slope.from, to: slope.to };
} else {
trimMin = slope.from.offset;
trimMinElevation = null;
trimMinSlope = { from: slope.from, to: slope.to };
}
}
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
return {
culvert,
pipe: { inlet: pipeStart, outlet, lengthM, slopePct },
pipeAxis: { axis, normal },
pipeCorners,
walls,
fillSlope: {
lengthM: outletSlope ? outletSlope.lengthM : fillSlopeLength,
withinLimit: outletSlope
? outletSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
: fillSlopeLength <= FILL_SLOPE_MAX_LENGTH_M + 1e-6,
ratio: outletSlope
? outletSlope.ratio
: (section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN),
ratioClamped: outletSlope ? outletSlope.clamped : false,
// 사면길이 5m 이상 = **기슭막이(구조물) 의무 구간**(성토_비탈면.md §2 —
// 2026-08-21 사용자 정정: 5m는 자리 한계가 아니라 의무 발생 기준이다).
structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M,
},
pitching: {
top: pitchingTop,
base: pitchingBase,
lengthM: pitchingLength,
minLengthM: minPitchingM,
},
basin,
revetShift: appliedShift,
designTrim:
walls.length || basin
? {
minOffset: trimMin,
maxOffset: trimMax,
...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}),
...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}),
...(trimMinSlope ? { minSlope: trimMinSlope } : {}),
...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}),
}
: null,
};
}