Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts
T
eomsangdonandClaude Opus 5 917cdce5df feat(B06): 집수정 9키 조작 · 좌우 화면 기준 통일 · 계류측 성토부 체계 도입
① 집수정 조작을 9키(3×3)로 — 십자 축은 이동(◀▶ 좌우 / ▲▼ 사면 대각),
   네 모서리는 크기(⇕ 내부 높이 · ⇔ 내부 폭), 가운데는 초기화. 기슭막이
   십자와 같은 축 규칙·버튼 크기라 행열이 맞는다. I형은 내공 개념이 없어
   네 모서리만 숨긴다.
② 좌우 반전 해소 — 집수정 lateralM이 계류측(outward) 기준값에 그대로 묶여
   유입이 우측인 측점에서 ◀가 화면 오른쪽으로 갔다. 버튼을 **화면 기준**으로
   받아 카드가 부호를 환산한다. 노견 안쪽(음수)은 0에서 멈추고 토스트로 알린다.
③ 집수정 계류측에 **유출측과 같은 성토부 체계** 적용(ㄴ·ㄷ형) — 구조물 계류측
   끝이 원지반 위로 나오면 1:1.2 성토부선을 긋고, 5m를 넘으면 다단 기슭막이를
   둘 수 있다(키 , 단 수 행 별도). 원지반 안이면 종전 절토선,
   I형은 관 하단 3도 되메움선이 맡는다. 좌우 이동 시 마감선을 억제하던 임시
   조건은 제거했다(급경사 선의 근본 원인은 이미 설계선 트림에서 해결).
- 700줄 유지: attachBasin(Basin)·buildExtrasAt(Extra) 추출 + 주석 정리

검증: 4+4.3 ㄴ형 좌우 2m — 성토부선 2.41m가 원지반에 0.001m 오차로 접함
(종전 마감선 없음) · ◀ 클릭 시 4+4.3(유입 좌)·13+15.7(유입 우) 모두 화면
왼쪽으로 이동 · 9키 3×3 좌표 정렬 확인 · tsc·ruff·prettier 통과 ·
pytest 148 passed, 7 skipped · B06_Section 700줄 초과 0건.

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

518 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Extra.ts
* 유출측 **성토부선 + 추가 기슭막이(다단)** — Geom에서 분리(700줄 제한).
*
* 다단 기슭막이는 **1:1.2 고정 물매에서 안정적으로 성토고를 올리는 수단**이다
* (2026-08-22 사용자 확정 규칙):
* · 기슭막이 사이 성토사면은 **정확히 1:1.2** — 벽 높이를 자리에 맞춰 역산한다.
* · 아랫단 벽 상단은 윗단 벽 **하단(수평 기초선)을 뚫고 올라갈 수 없다**.
* · 다음 단 성토선 시작점 = 윗단 벽 **하단 수평선 +0.5m와 전면 경사선(1:0.3)의
* 교차점**(배관 기슭막이의 관 하단 꼭짓점과 같은 자리 — 배관만 없다).
* · 그 시작점이 원지반 아래면(벽이 0.5m 이상 묻힘) **성토 불필요** — 다단 종료.
* ========================================================================== */
import {
EXTRA_WALL_DEFAULT_HEIGHT_M,
PIPE_CONNECT_GRADE,
EXTRA_WALL_MIN_HEIGHT_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MIN,
materialLabel,
materialLimit,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type {
OffsetPoint,
OutletFillSegment,
WallAdjust,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
export interface OutletExtrasInput {
/** 성토부선 시작점 = 관 유출 하단 꼭짓점(배관 벽의 성토선 시작 자리). */
start: OffsetPoint;
/** 배관 벽 하단(수평 기초) 표고 — 1단 벽 상단이 이 아래에 있어야 한다. */
startBottomElevation: number;
outward: number;
groundAt: (offset: number) => number;
/** 계류측 샘플 한계 offset. */
limitOffset: number;
/** 단별 사용자 조작값(좌우 x·상하 d·높이 h·재질 m) — 요청한 단 수만큼. */
adjusts: WallAdjust[];
/** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */
equalize?: boolean;
}
export interface OutletExtrasResult {
walls: WallLayout[];
segments: OutletFillSegment[];
/** 한계에 잘린 뒤의 실제 조작값 — 조정창이 되받는다. */
appliedAdjusts: WallAdjust[];
/** 끝 성토부가 아직 5m 이상 — 단을 더 둘 수 있다(의무 구간). */
addable: boolean;
}
/** 수평선(0도)이 계류측으로 나가며 원지반 아래로 다시 떨어지는 첫 지점(절토 교차).
* 없으면 null — 그 자리는 매몰 상태로 둘 수 없다(2026-08-22 사용자 ③ 이동 금지). */
export function slopedCrossing(
from: OffsetPoint,
outward: number,
grade: number,
groundAt: (offset: number) => number,
limitOffset: number,
): number | null {
const span = (limitOffset - from.offset) * outward;
const step = 0.25;
let previousGap = groundAt(from.offset) - from.elevation; // 지반 − 선 (양수 = 지반 위)
for (let t = step; t <= span + 1e-9; t += step) {
const x = from.offset + outward * t;
const gap = groundAt(x) - (from.elevation + grade * t);
if (gap <= 0) {
// 격자 사이 정확 교차점을 선형 보간 — 격자점을 그대로 쓰면 선이 지반을
// 미세하게 벗어난다(2026-08-22 사용자 지적).
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
return x - outward * step * (1 - fraction);
}
previousGap = gap;
}
return null;
}
/**
* 유입 기슭막이의 관 시작 접속선(2026-08-22 사용자 ②).
* · 관 하단점이 원지반보다 **위**: 0도 성토선 — 지반과 만나는 곳에서 정지.
* · **아래**: 0도 직선 1m 후 표준 절토 경사(1:n)로 지반까지 올라가는 절토선.
*/
export function inletGroundConnector(
corner: OffsetPoint,
outward: number,
groundAt: (offset: number) => number,
cutSlopeRatio: number,
limitOffset: number,
): OutletFillSegment | null {
const ground = groundAt(corner.offset);
const span = (limitOffset - corner.offset) * outward;
if (corner.elevation > ground + 0.02) {
// 3도 성토선 — 계류 쪽이 높다(물이 관으로). 지반 교차점(보간)에서 정지.
let end: OffsetPoint = {
offset: corner.offset + outward * Math.max(span, 0),
elevation: corner.elevation + PIPE_CONNECT_GRADE * Math.max(span, 0),
};
let previousGap = corner.elevation - groundAt(corner.offset); // 선 지반
for (let t = 0.25; t <= span + 1e-9; t += 0.25) {
const x = corner.offset + outward * t;
const lineElevation = corner.elevation + PIPE_CONNECT_GRADE * t;
const gap = lineElevation - groundAt(x);
if (gap <= 0) {
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const tCross = t - 0.25 * (1 - fraction);
end = {
offset: corner.offset + outward * tCross,
elevation: corner.elevation + PIPE_CONNECT_GRADE * tCross,
};
break;
}
previousGap = gap;
}
return {
points: [corner, end],
lengthM: Math.hypot(end.offset - corner.offset, end.elevation - corner.elevation),
overLimit: false,
kind: "fill",
};
}
if (corner.elevation < ground - 0.02) {
// 3도 직선 1m(계류 쪽이 높다) 후 표준 절토 경사.
const bend: OffsetPoint = {
offset: corner.offset + outward * 1.0,
elevation: corner.elevation + PIPE_CONNECT_GRADE * 1.0,
};
let last = bend;
const cutSpan = (limitOffset - bend.offset) * outward;
let previousGap = groundAt(bend.offset) - bend.elevation; // 지반 절토선
for (let t = 0.25; t <= Math.max(cutSpan, 0) + 1e-9; t += 0.25) {
const x = bend.offset + outward * t;
const elevation = bend.elevation + t / Math.max(cutSlopeRatio, 0.1);
const gap = groundAt(x) - elevation;
if (gap <= 0) {
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const tCross = t - 0.25 * (1 - fraction);
last = {
offset: bend.offset + outward * tCross,
elevation: bend.elevation + tCross / Math.max(cutSlopeRatio, 0.1),
};
break;
}
last = { offset: x, elevation };
previousGap = gap;
}
return {
points: [corner, bend, last],
lengthM: 1 + Math.hypot(last.offset - bend.offset, last.elevation - bend.elevation),
overLimit: false,
kind: "cut",
};
}
return null;
}
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
function jointRunOf(height: number): number {
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
}
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
function trailingFill(
src: OffsetPoint,
outward: number,
groundAt: (offset: number) => number,
): OutletFillSegment {
const points: OffsetPoint[] = [src];
let length = 0;
let previous = src;
const step = 0.25;
let previousGap = src.elevation - groundAt(src.offset); // 사면선 지반
for (let t = step; t <= 30 + 1e-9; t += step) {
const offset = src.offset + outward * t;
const slopeElevation = src.elevation - t / FILL_SLOPE_RATIO_MIN;
const gap = slopeElevation - groundAt(offset);
if (gap <= 0) {
// 끝점은 격자 대신 **보간 교차점**에 붙인다(2026-08-22 사용자 — 미세 이탈 정정).
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
const tCross = t - step * (1 - fraction);
const point: OffsetPoint = {
offset: src.offset + outward * tCross,
elevation: src.elevation - tCross / FILL_SLOPE_RATIO_MIN,
};
length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation);
points.push(point);
break;
}
const point: OffsetPoint = { offset, elevation: slopeElevation };
length += Math.hypot(point.offset - previous.offset, point.elevation - previous.elevation);
points.push(point);
previous = point;
previousGap = gap;
}
return {
points,
lengthM: length,
ratio: FILL_SLOPE_RATIO_MIN,
overLimit: length >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
};
}
/**
* 추가 기슭막이 벽 1매 — 배관용 기슭막이와 같은 형상(배면 수직·전면 1:0.3,
* 수평 기초 근입 0.5m), 배관·마감면만 없다. 높이는 눈금 없이 **정확값**을 쓴다 —
* 0.1m 올림을 하면 상단이 1:1.2 사면선을 벗어난다(사이 물매 정확 유지가 우선).
*/
function buildExtraWall(
index: number,
anchor: OffsetPoint,
outward: number,
height: number,
material: RevetMaterial,
floatGapM: number,
groundAt: (offset: number) => number,
): WallLayout {
const thickness = REVET_THICKNESS_M;
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 };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
// 하단 = 기준선(anchor) 아래 근입 0.5m — 근입은 **경사선(전면) 측 깊이** 기준이라
// 전면 발끝 지반이 낮으면 그 아래 0.5까지 더 내린다(2026-08-22 사용자 ②).
let bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
if (floatGapM <= 1e-6) {
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,
};
return {
role: "extra",
extraIndex: index,
form: materialLabel(material),
lengthM: null,
backOffset,
outerOffset: bottomFront.offset,
base: anchor.elevation,
height,
floatGapM,
material,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
bottomBack,
bottomFront,
points: [bottomBack, topBack, { offset: topFront, elevation: topElevation }, bottomFront],
};
}
/**
* 유출측 성토부선·다단 기슭막이 일괄 계산 (2026-08-22 4축 조작 체계).
*
* 단별 벽 높이 h = 사용자 설정 ?? 기본 1.5m (0.5~재질 한계로 절삭). 벽 상단은
* 항상 src에서 내려오는 **1:1.2 성토선 위** — 자동 자리는 높이 h 벽이 지반에
* 앉으며 상단이 선에 닿는 지점(heightAt(x)=h 교차점, 닫힌식):
* heightAt(x) = (src.elev 지반(x) (D 0.25t)/1.2) / (1 0.15/1.2)
* 조작: 좌우 x = src 높이의 **수평 선반**을 끼워 평행이동(물매 불변, 상단 표고
* 불변 — 윗단 기준 0.5m 수평 구현), 상하 d = 성토선을 타는 대각(수평 성분).
* 위(−d) 한계 = 윗단 하단 관통 금지(상단 ≤ 윗단 하단). 바닥은 상단−h−0.5로
* 지반과 무관 — 못 닿으면 뜨고(floatGap 경고), 지나면 묻힌다.
* 요청 단 수보다 지형이 허락하는 단이 적으면 되는 만큼만 세운다.
*/
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
const { outward, groundAt, limitOffset } = input;
const thickness = REVET_THICKNESS_M;
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
const walls: WallLayout[] = [];
const segments: OutletFillSegment[] = [];
const appliedAdjusts: WallAdjust[] = [];
let src = input.start;
let prevBottom = input.startBottomElevation;
for (let i = 0; i < input.adjusts.length; i += 1) {
// 시작점이 원지반 아래 = 윗단 벽이 0.5m 이상 묻힘 → 성토 불필요, 다단 종료.
if (groundAt(src.offset) >= src.elevation - 0.01) break;
const trailing = trailingFill(src, outward, groundAt);
if (trailing.lengthM < 0.7) break;
const adjust = input.adjusts[i];
const material = adjust.m ?? "dry";
const limit = materialLimit(material);
const height = Math.min(
Math.max(adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M, EXTRA_WALL_MIN_HEIGHT_M),
limit,
);
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
const heightAt = (x: number): number => {
const run = (x - src.offset) * outward;
return (
(src.elevation - groundAt(x) - (run - 0.25 * thickness) / FILL_SLOPE_RATIO_MIN) /
heightDenominator
);
};
// 자동 자리 = heightAt이 사용자 높이 h에 처음 닿는 지점(0.05m 스캔 후 선형 보간).
// 지형이 완만해 h까지 못 크면 **가장 깊이 앉는 지점**(heightAt 최대)으로 폴백 —
// 바닥은 상단−h−0.5라 지반과 어긋나도 된다(뜨면 경고, 묻히면 그대로).
const step = 0.05;
const span = (limitOffset - src.offset) * outward;
let autoOffset: number | null = null;
let bestOffset: number | null = null;
let bestHeight = 0.05; // 이 이하로만 앉는 지형이면 단을 세우지 않는다
let previousShort = 0; // 직전 스캔점의 h 부족량(heightAt < h) — 보간용
for (let t = step; t <= Math.max(span, 0) + 1e-9; t += step) {
const x = src.offset + outward * t;
const gap = heightAt(x) - height;
if (gap >= 0) {
// 교차점 선형 보간 — 격자 대신 정확 자리(사면선·지반 동시 접점).
const back = previousShort + gap > 1e-9 ? (gap / (previousShort + gap)) * step : 0;
autoOffset = x - outward * back;
break;
}
previousShort = -gap;
if (heightAt(x) > bestHeight) {
bestHeight = heightAt(x);
bestOffset = x;
}
}
if (autoOffset === null) autoOffset = bestOffset;
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
// 관통 금지: 상단 ≤ 윗단 하단 → 성토선 수평거리(경사부) ≥ 1.2×(시작−윗단하단).
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(height);
const minJointRun = FILL_SLOPE_RATIO_MIN * Math.max(src.elevation - prevBottom, 0);
// 등간격 배치: 1차는 남은 구간(끝 포함) 균등 분할 근사, 이후 라운드는 보정 d.
const requestedD =
plan === "user"
? adjust.d
: plan === "greedy"
? (trailing.lengthM / (input.adjusts.length - i + 1)) *
(FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN)) -
autoJointRun
: (plan[i] ?? 0);
const requestedX = plan === "user" ? adjust.x : 0;
const minD = Math.ceil((minJointRun - autoJointRun) * 10) / 10;
/** 후보 자리의 바닥(전면 근입 심화 반영) — buildExtraWall과 같은 계산. */
const bottomAt = (anchorX: number, baseElevation: number): number => {
const baseWidth = REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
const jointX = anchorX - outward * (baseWidth / 2 - REVET_THICKNESS_M / 2);
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const topElevation2 = baseElevation + height;
const frontXAt = (e: number): number =>
topFrontX + outward * REVET_LEAN_RATIO * (topElevation2 - e);
let bottom = baseElevation - REVET_EMBED_DEPTH_M;
if (baseElevation - groundAt(anchorX) <= 1e-6) {
for (let pass = 0; pass < 6; pass += 1) {
const toe = Math.min(groundAt(frontXAt(bottom)), baseElevation);
if (toe - REVET_EMBED_DEPTH_M >= bottom - 1e-6) break;
bottom = toe - REVET_EMBED_DEPTH_M;
}
}
return bottom;
};
// 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이
// 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이
// 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리).
const placeable = (xShift: number, dShift: number): boolean => {
const topE = src.elevation - (autoJointRun + dShift) / FILL_SLOPE_RATIO_MIN;
const aX = autoOffset + outward * (xShift + dShift);
const jointX = aX - outward * jointRunOf(height);
if (
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
topE - 0.01
)
return false;
const baseE = topE - height;
const bottom = bottomAt(aX, baseE);
const startE = bottom + REVET_EMBED_DEPTH_M;
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
if (groundAt(startX) < startE + 0.05) return true;
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
return (
slopedCrossing(
{ offset: startX, elevation: startE },
outward,
-PIPE_CONNECT_GRADE,
groundAt,
limitOffset,
) !== null
);
};
let appliedD = Math.max(requestedD, minD);
let appliedX = Math.max(requestedX, 0);
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) {
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
if (appliedD > minD + 1e-9) appliedD = Math.max(minD, appliedD - 0.05);
else if (appliedX > 1e-9) appliedX = Math.max(0, appliedX - 0.05);
else break;
}
if (!placeable(appliedX, appliedD)) break; // 자동 자리조차 매몰 — 이 단은 불가.
appliedD = Math.round(appliedD * 10) / 10;
appliedX = Math.round(appliedX * 10) / 10;
appliedAdjusts.push({
x: appliedX,
d: appliedD,
h: adjust.h != null ? height : null,
m: adjust.m,
});
const slopeRun = autoJointRun + appliedD;
const topElevation = src.elevation - slopeRun / FILL_SLOPE_RATIO_MIN;
const anchorX = autoOffset + outward * (appliedX + appliedD);
const base = topElevation - height; // 근입 0.5 위 기준선
const wall = buildExtraWall(
i,
{ offset: anchorX, elevation: base },
outward,
height,
material,
Math.max(0, base - groundAt(anchorX)),
groundAt,
);
walls.push(wall);
// 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부.
const shelf: OffsetPoint | null =
appliedX > 1e-9
? { offset: src.offset + outward * appliedX, elevation: src.elevation }
: null;
const slopeFrom = shelf ?? src;
const run = Math.abs(wall.topJoint.offset - slopeFrom.offset);
const rise = slopeFrom.elevation - wall.topJoint.elevation;
segments.push({
points: shelf ? [src, shelf, wall.topJoint] : [src, wall.topJoint],
lengthM: Math.hypot(run, rise),
ratio: rise > 1e-9 ? run / rise : FILL_SLOPE_RATIO_MIN,
overLimit: Math.hypot(run, rise) >= FILL_SLOPE_MAX_LENGTH_M - 1e-6,
});
// 다음 단 시작점 = 이 벽 **실제 바닥**(근입 심화 반영) +0.5m 수평선과 전면
// 경사선(1:0.3)의 교차점(2026-08-22 사용자 — 관 끝점 규칙과 동일 기준).
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
src = {
offset:
wall.points[2].offset +
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
};
prevBottom = wall.bottomBack.elevation;
}
// 끝 구간(2026-08-22 사용자 ③): 시작점이 지반 위면 성토부선, 지반 아래(매몰)면
// **0도 절토선**을 원지반 교차까지 연장한다(교차가 없는 자리는 placeable이 막는다).
let addable = false;
if (groundAt(src.offset) < src.elevation - 0.01) {
const tail = trailingFill(src, outward, groundAt);
segments.push(tail);
addable = tail.overLimit;
} else {
const cross = slopedCrossing(src, outward, -PIPE_CONNECT_GRADE, groundAt, limitOffset);
if (cross !== null) {
const end: OffsetPoint = {
offset: cross,
elevation: src.elevation - PIPE_CONNECT_GRADE * Math.abs(cross - src.offset),
};
segments.push({
points: [src, end],
lengthM: Math.hypot(end.offset - src.offset, end.elevation - src.elevation),
overLimit: false,
kind: "cut",
});
}
}
return { walls, segments, appliedAdjusts, addable };
};
if (!input.equalize) return cascadeOnce("user");
// 등간격(2026-08-22 ①) = **벽 사이 사면 구간**을 같게(끝 성토부는 지형이 정하는
// 값이라 제외). 다단은 아래 단 바닥이 깊어질수록 뒷구간이 줄어드는 연쇄라 1회
// 분할로는 안 맞는다 — 현 자리에서 구간 오차를 d로 되먹임하며 수렴시킨다.
let result = cascadeOnce("user");
const horizontalPerSlope = FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN);
for (let round = 0; round < 8; round += 1) {
const count = result.walls.length;
if (count < 2) break; // 한 단이면 등간격 개념이 없다
const lengths = result.segments.slice(0, count).map((segment) => segment.lengthM);
const mean = lengths.reduce((a, b) => a + b, 0) / lengths.length;
if (Math.max(...lengths.map((l) => Math.abs(l - mean))) < 0.05) break;
const dNext = result.appliedAdjusts.map(
(applied, i) => applied.d + (mean - (lengths[i] ?? mean)) * horizontalPerSlope,
);
result = cascadeOnce(dNext);
}
return result;
}
/** 성토부가 없는 쪽(구조물 미설치·매몰)의 빈 결과. */
export const NO_EXTRAS: OutletExtrasResult = {
walls: [],
segments: [],
appliedAdjusts: [],
addable: false,
};
/** start가 있을 때만 성토부·다단을 만든다 — 호출부의 삼항 중복을 없앤다. */
export function buildExtrasAt(
start: OffsetPoint | null,
rest: Omit<OutletExtrasInput, "start">,
): OutletExtrasResult {
return start ? buildOutletExtras({ ...rest, start }) : NO_EXTRAS;
}