Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts
T
eomsangdonandClaude Opus 5 97bf00a8ef fix(B06): 유출 관 끝점·전면 근입 기준 + 줌 유지·조정창 레이아웃 정리
- 유출 관 하단 끝점 = 벽 바닥 +0.5m 기준선과 전면 경사선(1:0.3)의 교차점
  (성토부선 시작점과 동일 자리) — 정수 맞춤 재배치에도 동일 적용
- 근입 0.5m는 경사선(전면) 측 깊이 기준 — 전면 발끝 지반 아래 0.5까지
  수렴 반복으로 내림(배관 벽·추가 벽 공통, 뜬 벽은 기준선 아래 0.5)
- 카드 내부 조작(조정창 등)으로 다시 그려져도 줌·팬 배율 유지
  (측점별 상태 보관, ZoomPanState)
- 조정창: 재질 행을 높이 행 위 별도 행으로(폭 축소 — 한계는 툴팁),
  추가 기슭막이(단) 행은 유출 벽에서 항상 표시(레이아웃 널뜀 방지)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 14:32:30 +09:00

695 lines
33 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Culvert_Geom.ts
* 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
* 백엔드 `section.culvert` 제원을 좌표로 옮긴다(치수 결정 금지). 배치 규칙은 실무
* 횡단도 기준(2026-08-20 사용자 제공, 울진 계열): 기슭막이 평행사변형(전면 1:0.3),
* 배관은 두 벽 사이 직선·끝단면은 구조물 변과 평행, 유출측은 관 하단 꼭짓점부터
* 성토부선(보호공 삭제 — 2026-08-22), 성토 경사선은 벽 접점에서 끊는다(designTrim).
* 유입 = 상단측(uphill_side). 집수정 구성은 `_Cross_Culvert_Basin.ts` 참조.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
BASIN_INNER_HEIGHT_M,
BASIN_MAX_FILL_SLOPE_M,
FILL_MIN_RISE_M,
materialFromForm,
materialLabel,
materialLimit,
PIPE_WALL_DEFAULT_HEIGHT_M,
PIPE_WALL_MIN_HEIGHT_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
MIN_PIPE_COVER_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
BasinShape,
CulvertLayout,
EndFace,
OffsetPoint,
PipeEnd,
WallAdjust,
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 {
applyBasinPipeFill,
BASIN_INNER_WIDTH_M,
buildBasin,
inletChoiceAvailability,
} from "./B06_Section_UI_Cross_Culvert_Basin";
import { buildOutletExtras } from "./B06_Section_UI_Cross_Culvert_Extra";
import type { FillSlopeSegment, WallVertical } from "./B06_Section_UI_Cross_Culvert_Solve";
import {
clampToFace,
cutLength,
minShoulderWallOffset,
outletSlopeFactory,
fillSlopeOf,
groundInterpolator,
designInterpolator,
intersect,
slopeToeOffset,
slopeLengthAlong,
STRAY_LIMIT_M,
} from "./B06_Section_UI_Cross_Culvert_Solve";
/** 유입측 구조물 사용자 선택(2026-08-22) — auto = 규칙(사면≤3m → 집수정 ㄴ형). */
export type InletStructureChoice = "auto" | "revet" | "I" | "L" | "U";
/** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */
export function computeCulvertLayout(
section: CrossSection,
groundSamples: SectionSample[],
/** 사용자 조작값(좌우 x·상하 d·높이 h·재질 m — 2026-08-22 4축). 없으면 자동. */
revetShift?: { inlet?: WallAdjust; outlet?: WallAdjust; extras?: WallAdjust[] },
/** 유입측 구조물 형식 선택(드롭다운) — 없으면 auto(규칙). */
inletStructure?: InletStructureChoice,
): 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 };
// 유입측 집수정 판정(2026-08-22 사용자 재정의): **성토사면 길이 ≤ 3m**면 기슭막이
// +배관을 두기 어려워 집수정이 기본이 된다(종전 절토/30% 막힘 판정 대체 — 절토측
// 유입은 성토사면이 없으므로 자동 포함). 사용자 선택(기슭막이 유지)은 향후 추가.
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");
const inletFillSlopeLen =
!inletIsCut && designAt
? slopeLengthAlong(
designAt,
inletInfo.edge.offset_m,
slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit),
)
: 0;
const ruleReason: BasinLayout["reason"] | null = inletIsCut
? "cut"
: culvert.inlet.structure === "집수정"
? "cut"
: inletFillSlopeLen <= BASIN_MAX_FILL_SLOPE_M + 1e-6
? "short"
: null;
// 사용자 선택(드롭다운 — 2026-08-22)이 규칙보다 우선한다:
// revet = 기슭막이+배관 강제(양측성토 로직), I/L/U = 해당 형식 집수정 강제.
const choice: InletStructureChoice = inletStructure ?? "auto";
const basinReason: BasinLayout["reason"] | null =
choice === "revet"
? null
: choice === "I" || choice === "L" || choice === "U"
? (ruleReason ?? "manual")
: ruleReason;
// 집수정 형식 기본값 = ㄴ(L)형(2026-08-20 확정) — 선택 시 그 형식.
const basinShape: BasinShape = choice === "I" || choice === "L" || choice === "U" ? choice : "L";
// ㄴ·ㄷ형은 사이즈 유지한 채 통째로 노견 끝점 일치(2026-08-22 확정) — 바닥(=관
// 유입 invert)이 따라 올라 물매도 바뀐다. I형은 원지반 배관 유지(상세 Basin 참조).
if (basinReason && basinShape !== "I") {
inlet.elevation = inletInfo.edge.elevation_m - BASIN_INNER_HEIGHT_M;
} else if (basinReason) {
// I형 — 관 시작점(내공 1.0m 자리) invert = 그 x의 원지반(토피 상한 이내).
const startOffset = inlet.offset + inletInfo.outward * BASIN_INNER_WIDTH_M;
inlet.elevation = Math.min(groundAt(startOffset), invertCap(inletInfo.edge));
}
// 벽 제원(2026-08-22 4축 조작): 재질(기본 메쌓기)이 높이 한계(메2/찰3/콘5)를
// 정하고, 높이 = 사용자 설정 ?? 기본 2.5(한계로 절삭). 하한은 2.0과 관경+여유고
// 중 큰 값 — 관이 벽 위로 삐져나오면 안 된다. 자동 자리 계산도 이 높이 기준.
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const material = adjust.m ?? materialFromForm(spec.revet_form);
const limit = materialLimit(material);
const floor = Math.max(PIPE_WALL_MIN_HEIGHT_M, revetTargetHeight(diameter));
const height = Math.min(
Math.max(adjust.h ?? Math.min(PIPE_WALL_DEFAULT_HEIGHT_M, limit), floor),
Math.max(limit, floor),
);
return { material, limit, height };
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 조정창 드롭다운 선택지 가용성 — 판정은 분리 파일(Basin)이 맡는다(700줄 제한).
const inletOptions = inletChoiceAvailability({
designAt,
groundAt,
edge: inletInfo.edge,
outward: inletInfo.outward,
limitOffset: inletInfo.limit,
wallHeight: inletWallSpec.height,
invertCapM: invertCap(inletInfo.edge),
diameterM: diameter,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
});
/** 요청값이 한계에 걸려 잘린 뒤의 **실제** 조작값. 조정창이 이 값을 되받는다. */
const appliedAdjust = {
inlet: { ...adjInlet, h: adjInlet.h != null ? inletWallSpec.height : null },
outlet: { ...adjOutlet, h: adjOutlet.h != null ? outletWallSpec.height : null },
};
/** 손으로 민 벽의 수직 제원(높이 증가·바닥 띄움) — buildWall에 물려 준다. */
const wallVertical: { inlet: WallVertical | null; outlet: WallVertical | null } = {
inlet: null,
outlet: null,
};
// 기슭막이 자동 자리 = **노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정)
// — 표준 높이 벽으로 물매 1:1.2가 딱 성립하는 가장 안쪽 자리. 관 m단위 올림 잔여가
// 벽을 바깥으로 밀면 그만큼만 노견이 조정된다. 못 찾으면 사면 끝으로 폴백.
let inletAutoOffset: number | null = null;
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 자동 자리 = 가용성 판정과 같은 스캔 결과(노견 최소 지점 ?? 사면 끝) 재사용.
inletAutoOffset = inletOptions.revetAutoOffset;
// 4축 조작(2026-08-22 확정): 좌우 x = 노견 연장 **평행이동**(상단 표고·물매
// 1:1.2 불변, 안쪽 x<0 금지 — 자동 자리가 이미 노견 최소), 상하 d = 성토선을
// 타는 대각 이동(수평 성분, +아래). 위(−d) 한계 = 최소 성토고·토피 상한.
// 바닥이 지반에 못 닿으면 떠도 무관(별도 지지 구조물 예정).
const h = inletWallSpec.height;
const top0 = inletBaseAt(inletAutoOffset) + h;
const dLow = Math.max(
-FILL_SLOPE_RATIO_MIN * (inletInfo.edge.elevation_m - FILL_MIN_RISE_M - top0),
-FILL_SLOPE_RATIO_MIN * (invertCap(inletInfo.edge) - (top0 - h)),
);
const appliedD = Math.max(adjInlet.d, Math.ceil(dLow * 10) / 10);
const appliedX = Math.max(adjInlet.x, 0);
appliedAdjust.inlet.x = appliedX;
appliedAdjust.inlet.d = appliedD;
const topElev = top0 - appliedD / FILL_SLOPE_RATIO_MIN;
const anchorX = inletAutoOffset + inletInfo.outward * (appliedX + appliedD);
const invert = topElev - h;
wallVertical.inlet = {
height: h,
baseElevation: invert,
floatGapM: Math.max(0, invert - inletBaseAt(anchorX)),
};
inlet.offset = anchorX;
inlet.elevation = invert;
}
// 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2).
// 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면).
const outletAnchorOffset = designAt
? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit)
: outletInfo.edge.offset_m;
// 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①).
const fillSlopeLength = designAt
? slopeLengthAlong(designAt, outletInfo.edge.offset_m, outletAnchorOffset)
: 0;
// 역경사는 수평으로 클램프(수평 가능 — 사용자 확정).
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: { points: OffsetPoint[] } | null = null;
let trimMaxSlope: { points: OffsetPoint[] } | null = null;
const buildWall = (
spec: CulvertSideSpec,
anchor: OffsetPoint,
outward: number,
forceBasinReason: BasinLayout["reason"] | null,
thickness: number = REVET_THICKNESS_M,
vertical: WallVertical | null = null,
material: RevetMaterial = "dry",
): WallLayout | null => {
const reason: BasinLayout["reason"] | null =
// 백엔드 spec의 "집수정"은 판정(ruleReason)에 이미 반영됐다 — 사용자 선택
// revet(기슭막이 강제)를 존중하려면 여기서 다시 살리면 안 된다(2026-08-22).
forceBasinReason;
if (reason) {
// 집수정 구성은 분리 파일(_Cross_Culvert_Basin.ts — 700줄 제한)이 맡는다.
const built = buildBasin({
anchor,
outward,
shape: basinShape,
reason,
diameterM: diameter,
edge: spec.role === "inlet" ? inletInfo.edge : outletInfo.edge,
groundAt,
cutSlopeRatio: section.design?.cut_slope_ratio ?? 1.0,
});
basin = built.basin;
basinPipeEnd = built.pipeEnd;
endFaces[spec.role] = built.endFace;
// 성토 사면선은 벽 상단 도로측 꼭짓점에서 끊고, 끝단 표고도 거기 맞춘다.
if (outward > 0) {
trimMax = built.trimOffset;
trimMaxElevation = built.trimElevation;
} else {
trimMin = built.trimOffset;
trimMinElevation = built.trimElevation;
}
return null;
}
// 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): 배면(도로측) 수직, 전면
// (계류측)은 1:0.3 기운 평행사변형 띠. 높이는 4축 조작 제원(vertical)이 들고
// 들어온다 — 계산용 높이(근입 0.5 위 기준선~상단), 재질 한계는 pipeWallSpec 절삭.
const height =
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const floatGapM = vertical?.floatGapM ?? 0;
// **자리 기준 = 하단선 중점**(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 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;
// 하단선: **수평 기초**, 깊이 = 기준선(관 invert) 아래 근입 0.5m 고정 —
// 높이 기준을 "근입 0.5 위 기준선~상단"으로 확정하며 발자국 최저 지반 추적을
// 대체했다(2026-08-22 사용자). 지반이 더 낮으면 바닥이 뜨고(floatGapM 경고),
// 더 높으면 그만큼 더 묻힌다. 전면 경사선(1:0.3)은 하단까지 연장.
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
// 근입 0.5m는 **경사선(전면) 측 깊이** 기준(2026-08-22 사용자 ②) — 전면 발끝
// 지반이 더 낮으면 그 아래 0.5까지 내린다. 내려가면 발끝이 더 나가므로 수렴 반복.
for (let pass = 0; pass < 6; pass += 1) {
const toeGround = Math.min(groundAt(frontXAt(bottomElevation)), anchor.elevation);
if (toeGround - REVET_EMBED_DEPTH_M >= bottomElevation - 1e-6) break;
bottomElevation = toeGround - REVET_EMBED_DEPTH_M;
}
}
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
offset: frontXAt(bottomElevation),
elevation: bottomElevation,
};
const wall: WallLayout = {
role: spec.role,
form: materialLabel(material),
lengthM: spec.revet_length_m ?? null,
backOffset,
outerOffset: bottomFront.offset,
base: anchor.elevation,
// 높이 = **계산용 높이(관 invert~상단, 근입 제외)로 통일**(2026-08-22 사용자
// 확정 — 기초는 시공 시 묻히는 부분이라 높이·한계 검사 모두 이 기준).
height,
floatGapM,
material,
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,
REVET_THICKNESS_M,
wallVertical.inlet,
inletWallSpec.material,
);
// 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는
// 1:1.2~2.0 범위에서 역산).
//
// 벽 밑 = 그 자리의 **원지반**. 종전처럼 관 축(inlet → 사면 끝 직선) 위에 얹으면
// 성토측 지반이 관 물매보다 가파른 구간에서 축이 지반 위로 떠올라 **벽이 뜬다**
// (2026-08-21 사용자 지적). 관 끝은 원지반 위라는 원칙대로 지반을 따르면 벽이
// 지반에 앉고 관 물매가 그 자리에 맞춰진다. 역경사는 유입 invert로 클램프.
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
// 유출 벽 자동 자리도 **노견 최소 지점**(2026-08-22 사용자 확정) — 못 찾으면 사면 끝.
let outletWallAnchor = outletAnchor;
const outletFeasible = designAt
? minShoulderWallOffset(
outletInfo.edge,
outletInfo.outward,
outletWallSpec.height,
invertAt,
outletAnchorOffset,
outletInfo.limit,
)
: null;
const outletAutoOffset = outletFeasible ?? outletAnchorOffset;
if (designAt) {
// 4축 조작(유입과 동일). 위(−d) 한계 = 최소 성토고 + 역경사 방지(invert ≤ 유입).
const h = outletWallSpec.height;
const top0 = invertAt(outletAutoOffset) + h;
const dLow = Math.max(
-FILL_SLOPE_RATIO_MIN * (outletInfo.edge.elevation_m - FILL_MIN_RISE_M - top0),
-FILL_SLOPE_RATIO_MIN * (inlet.elevation - (top0 - h)),
);
const appliedD = Math.max(adjOutlet.d, Math.ceil(dLow * 10) / 10);
const appliedX = Math.max(adjOutlet.x, 0);
appliedAdjust.outlet.x = appliedX;
appliedAdjust.outlet.d = appliedD;
const topElev = top0 - appliedD / FILL_SLOPE_RATIO_MIN;
const anchorX = outletAutoOffset + outletInfo.outward * (appliedX + appliedD);
const invert = topElev - h;
wallVertical.outlet = {
height: h,
baseElevation: invert,
floatGapM: Math.max(0, invert - invertAt(anchorX)),
};
outletWallAnchor = { offset: anchorX, elevation: invert };
}
let outletWall = buildWall(
culvert.outlet,
outletWallAnchor,
outletInfo.outward,
null,
REVET_THICKNESS_M,
wallVertical.outlet,
outletWallSpec.material,
);
// ── 관 축 확정. 관 하단선은 **관 시작점(집수정 내공 벽 또는 유입 벽 자리)과
// 유출 벽 하단선 중점(invert)을 잇는 직선**이다 — 벽이 어디로 가든 관은 그 벽
// 바닥을 지난다(2026-08-22 사용자: 기슭막이는 관을 감싸는 구조물). 종전에는 정수
// 맞춤이 벽을 옮긴 뒤에도 옛 축을 그대로 써서, 가파른 지반에서 관이 벽 바닥
// 아래로 삐져나갔다(13+15.7 사용자 지적).
const pipeStart = basinPipeEnd ?? inlet;
/** 유출 관 하단 끝점 목표 = 벽 바닥 기준 0.5m 상단(기준선)과 **전면 경사선의
* 교차점**(2026-08-22 사용자 ① — 성토부선 시작 규칙과 같은 자리). */
const outletPipeEnd = (wall: WallLayout): OffsetPoint => ({
offset:
wall.points[2].offset +
wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - wall.base),
elevation: wall.base,
});
// 유출 벽이 사면 끝과 다른 자리면 관 끝도 그 자리 기준으로 맞춘다(올림 연장분은
// 유출 쪽 — 2026-08-20 확정). 관 하단선은 전면 기준선 교차점을 지나 정수 길이로.
if (outletWall) {
const face = outletPipeEnd(outletWall);
const runW = face.offset - pipeStart.offset;
const riseW = face.elevation - pipeStart.elevation;
const lenW = Math.hypot(runW, riseW);
if (lenW > 0.5) {
const scaleW = Math.ceil(lenW - 1e-6) / lenW;
outlet.offset = pipeStart.offset + runW * scaleW;
outlet.elevation = pipeStart.elevation + riseW * scaleW;
lengthM = Math.ceil(lenW - 1e-6);
}
}
// ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변에 맞춰 자른다(2026-08-20 사용자 ③).
// 보호공 시작점이 **관 하단 꼭짓점**을 그대로 써야 하므로 여기서 확정한다.
// 축·법선은 정수 맞춤이 벽(=관 끝)을 옮길 때마다 다시 유도한다.
let axis: OffsetPoint = { offset: 1, elevation: 0 };
let normal: OffsetPoint = { offset: 0, elevation: 1 };
let topAnchor: OffsetPoint = pipeStart;
const deriveAxis = (): void => {
const runP = outlet.offset - pipeStart.offset;
const riseP = outlet.elevation - pipeStart.elevation;
const axisLength = Math.hypot(runP, riseP) || 1;
axis = { offset: runP / axisLength, elevation: riseP / axisLength };
normal = { offset: -axis.elevation, elevation: axis.offset };
if (normal.elevation < 0) normal = { offset: -normal.offset, elevation: -normal.elevation };
topAnchor = {
offset: pipeStart.offset + normal.offset * diameter,
elevation: pipeStart.elevation + normal.elevation * diameter,
};
};
deriveAxis();
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;
if (strayed) return fallback;
return { bottom: clampToFace(bottom, face), top: clampToFace(top, face) };
};
let pipeCorners = {
inlet: endCorners("inlet", pipeStart),
outlet: endCorners("outlet", outlet),
};
// ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①). 관 끝은 기운 벽 전면으로 잘려 **상단·하단
// 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리**
// 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과
// 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②).
const outletSlopeAt = outletSlopeFactory(outletInfo, invertAt, outletWallSpec.height);
/** 유출 벽 두께 — 항상 0.45 고정(2026-08-21 사용자 확정). 폭으로 길이를 맞추지 않는다. */
const outletThickness = REVET_THICKNESS_M;
// 손으로 민 벽은 정수 맞춤이 **절대 옮기지 않는다**(2026-08-22 사용자 확정 — 종전에는
// 한쪽 벽을 밀면 이 루프가 반대쪽 벽을 따라 옮겨 좌우가 동시에 움직였다). 관 그림은
// 두 벽 전면 사이 실길이로 두고 표기 길이만 올림(m)한다.
const outletPinned = Math.abs(adjOutlet.x) > 1e-9 || Math.abs(adjOutlet.d) > 1e-9;
if (outletWall && !outletPinned) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
const rebuild = (): void => {
walls.pop();
outletWall = buildWall(
culvert.outlet,
outletWallAnchor,
outletInfo.outward,
null,
outletThickness,
{
height: outletWallSpec.height,
baseElevation: outletWallAnchor.elevation,
floatGapM: Math.max(0, outletWallAnchor.elevation - invertAt(outletWallAnchor.offset)),
},
outletWallSpec.material,
);
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
const runW = face.offset - pipeStart.offset;
const riseW = face.elevation - pipeStart.elevation;
const lenW = Math.hypot(runW, riseW) || 1;
outlet.offset = pipeStart.offset + (runW / lenW) * target;
outlet.elevation = pipeStart.elevation + (riseW / lenW) * target;
deriveAxis();
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) + outletWallSpec.height) < FILL_MIN_RISE_M
) {
break;
}
// 자동 자리만 옮긴다(수동 벽은 이 루프에 아예 안 들어온다). 폭은 0.45 고정 —
// 폭으로 흡수하면 미는 동안 벽 단면이 변한다(2026-08-21 사용자 지적).
if (
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;
}
}
if (outletWall) {
// 표기 길이 = **실제 그려진 관**의 올림값(목표를 적으면 도면보다 길게 적힌다).
// 수동 벽으로 실길이가 정수가 아니어도 표기만 올림한다(2026-08-22 사용자 확정).
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;
}
// I형 집수정: 관 하단 꼭짓점이 지반 위에 뜨면 수평 되메움선(Basin 분리 — 700줄).
if (basin) {
applyBasinPipeFill(basin, pipeCorners.inlet.bottom, inletInfo.outward, groundAt);
}
// 성토부선 + 추가 기슭막이(2026-08-22 사용자 — 보호공 삭제, 윗면 선만 성토부선으로
// 남긴다). 끝 구간이 5m 이상이면 사용자가 추가 기슭막이(벽만)를 계단식으로 더 둔다.
const extras = outletWall
? buildOutletExtras({
start: pipeCorners.outlet.bottom,
startBottomElevation: outletWall.bottomBack.elevation,
outward: outletInfo.outward,
groundAt,
limitOffset: outletInfo.limit,
adjusts: (revetShift?.extras ?? []).map(adjustOf),
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
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.points[0].offset;
trimMaxElevation = null;
trimMaxSlope = { points: slope.points };
} else {
trimMin = slope.points[0].offset;
trimMinElevation = null;
trimMinSlope = { points: slope.points };
}
}
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
return {
inletOptions,
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,
roadWideningM: outletSlope ? outletSlope.wideningM : 0,
// 사면길이 5m 이상 = **기슭막이(구조물) 의무 구간**(성토_비탈면.md §2 —
// 2026-08-21 사용자 정정: 5m는 자리 한계가 아니라 의무 발생 기준이다).
structureRequired: outletSlope != null && outletSlope.lengthM >= FILL_SLOPE_MAX_LENGTH_M,
},
outletFill: { segments: extras.segments, addable: extras.addable },
extraWalls: extras.walls,
basin,
revetShift: {
inlet: appliedAdjust.inlet,
outlet: appliedAdjust.outlet,
extras: extras.appliedAdjusts,
},
designTrim:
walls.length || basin
? {
minOffset: trimMin,
maxOffset: trimMax,
...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}),
...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}),
...(trimMinSlope ? { minSlope: trimMinSlope } : {}),
...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}),
}
: null,
};
}