Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Extra.ts
T
eomsangdonandClaude Opus 5 13dda88058 feat(B06): 기슭막이 조정창을 공용화하고 형태 선택지를 B05 폼과 맞춘다
조정창은 한 컴포넌트였지만 행 표시가 역할 키에 묶여 있어, 같은 성토 벽이라도
유입/유출에 따라 창 구성이 갈렸다. 재질 선택지도 3종뿐이라 B05 폼 형태 6종을
조정창에서 고를 수 없었다.

- 단 수 행을 하나로 합치고 자리를 높이 행 아래로 고정한다. 저장 채널(extra /
  bextra)은 선택된 벽이 정하고, 등간격(≡)은 유출 채널 전용으로 제한한다
- 형태 선택지를 B05 폼과 같은 6종(REVET_FORMS)으로 바꾼다. 조작값 m의 의미를
  재질 코드에서 형태 문자열로 바꾸고, 옛 저장분은 revetFormOf가 옮겨 읽는다
- 높이 한계는 revetHeightLimit(form), 돌 표현은 materialFromForm(form)으로
  형태에서 파생한다. 벽 제원 계산은 revetWallSpec()으로 모아 배관·독립·추가가
  같은 함수를 쓴다
- 조정창 행 라벨 "재질" → "형태"

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

528 lines
24 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,
materialFromForm,
materialLabel,
revetFormOf,
revetHeightLimit,
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[];
/** 소유 벽(배관·독립 기준벽) 형태 — 단에 조작이 없으면 이 형태를 물려받는다. */
ownerForm?: string | null;
/** 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)로 지반까지 올라가는 절토선.
* 지반 교차가 안 나오는 지형은 1m 공간 확보에서 끝(2026-08-23 사용자).
*/
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(계류 쪽이 높다) 후 표준 절토 경사로 지반까지. 교차가 안
// 나오면 절토선 없이 **1m 공간 확보에서 끝**(2026-08-23 사용자 — 종전에는
// 샘플 한계까지 올라가는 긴 선이 남았다).
const bend: OffsetPoint = {
offset: corner.offset + outward * 1.0,
elevation: corner.elevation + PIPE_CONNECT_GRADE * 1.0,
};
const cutSpan = (limitOffset - bend.offset) * outward;
let cross: OffsetPoint | null = null;
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);
cross = {
offset: bend.offset + outward * tCross,
elevation: bend.elevation + tCross / Math.max(cutSlopeRatio, 0.1),
};
break;
}
previousGap = gap;
}
return {
points: cross ? [corner, bend, cross] : [corner, bend],
lengthM: cross
? 1 + Math.hypot(cross.offset - bend.offset, cross.elevation - bend.elevation)
: 1,
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,
form: string | null = null,
): 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 **고정** — 원지반에 묻혀도 바닥을 더
// 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 —
// 종전 전면 발끝 지반 추적 수렴 삭제).
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: "extra",
extraIndex: index,
form: 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];
// 형태(B05 폼 6종)가 재질·높이 한계를 정한다 — 조작이 없으면 소유 벽 형태를 따른다
// (2026-08-29 사용자: 조정창 선택지를 형태로 통일).
const form = revetFormOf(adjust.m, input.ownerForm);
const material = materialFromForm(form);
const limit = revetHeightLimit(form);
// 높이 기준 = **벽 바닥~상단**(근입 0.5 포함) — 배관 기준벽과 같은 기준으로
// 통일했다(2026-08-29 사용자: 추가 기슭막이 높이가 하단~상단이 아니었다).
const height = Math.min(
Math.max(
adjust.h ?? EXTRA_WALL_DEFAULT_HEIGHT_M,
EXTRA_WALL_MIN_HEIGHT_M,
REVET_EMBED_DEPTH_M + 0.1,
),
limit,
);
/** 기준선(근입 위)~상단 — 도형·자리 계산은 종전대로 이 값으로 한다. */
const exposed = height - REVET_EMBED_DEPTH_M;
/** 자리 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) - exposed;
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; // 세울 만한 지형이 아니다 — 되는 만큼만.
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed);
/** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */
const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN;
// 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다.
const minV = Math.ceil(Math.max(autoTop - prevBottom, 0) * 10) / 10;
// 조작 축(2026-08-29 사용자): d = **순수 수직 하강(m)**, x = **순수 수평 이동(m)**.
// 성토선 각도(1:1.2)는 그대로 두고, 윗단 앞 **수평 선반**이 길이를 흡수한다
// 선반 = x − 1.2·d (≥ 0이어야 각도가 유지된다).
// 등간격 배치: 1차는 남은 구간(끝 포함) 균등 분할 근사, 이후 라운드는 보정값.
const requestedD =
plan === "user"
? (adjust.d ?? 0)
: plan === "greedy"
? ((trailing.lengthM / (input.adjusts.length - i + 1)) *
(FILL_SLOPE_RATIO_MIN / Math.hypot(1, FILL_SLOPE_RATIO_MIN)) -
autoJointRun) /
FILL_SLOPE_RATIO_MIN
: (plan[i] ?? 0);
const requestedX = plan === "user" ? adjust.x : 0;
/** 후보 자리의 바닥 — buildExtraWall과 같은 근입 0.5m 고정(2026-08-23). */
const bottomAt = (_anchorX: number, baseElevation: number): number =>
baseElevation - REVET_EMBED_DEPTH_M;
// 이동 한계 = 원지반 매몰 자리 금지(2026-08-22 사용자 확정): ① 이음선 상단이
// 지반 아래(벽이 통째로 묻힘) ② 자기 성토선 시작점(실바닥+0.5 전면 교차점)이
// 지반 아래 0.05m 이상(= 0.5m 이상 매몰 → 성토 불필요 자리).
const placeable = (xShift: number, vShift: number): boolean => {
const topE = autoTop - vShift;
const aX = autoOffset + outward * xShift;
const jointX = aX - outward * jointRunOf(exposed);
if (
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
topE - 0.01
)
return false;
const baseE = topE - exposed;
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, minV);
/** 선반 길이가 음수가 되면 각도가 깨진다 — x가 최소 1.2·d까지 따라 나간다. */
const shelfFloor = (v: number): number => FILL_SLOPE_RATIO_MIN * v;
let appliedX = Math.max(requestedX, 0, shelfFloor(appliedD));
for (let pass = 0; pass < 400 && !placeable(appliedX, appliedD); pass += 1) {
// 매몰 자리 — 요청을 자동 자리 쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
if (appliedD > minV + 1e-9) {
appliedD = Math.max(minV, appliedD - 0.05);
appliedX = Math.max(appliedX, shelfFloor(appliedD));
} else if (appliedX > shelfFloor(appliedD) + 1e-9) {
appliedX = Math.max(shelfFloor(appliedD), 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 topElevation = autoTop - appliedD;
const anchorX = autoOffset + outward * appliedX;
const base = topElevation - exposed; // 근입 0.5 위 기준선
const wall = buildExtraWall(
i,
{ offset: anchorX, elevation: base },
outward,
exposed,
material,
Math.max(0, base - groundAt(anchorX)),
form,
);
walls.push(wall);
// 성토선: src → (수평 선반 x>0이면 선반 끝) → 이음선 상단점. 사면길이 = 경사부.
const shelfRun = Math.max(0, appliedX - FILL_SLOPE_RATIO_MIN * appliedD);
const shelf: OffsetPoint | null =
shelfRun > 1e-9
? { offset: src.offset + outward * shelfRun, 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 ?? 0) +
((mean - (lengths[i] ?? mean)) * horizontalPerSlope) / FILL_SLOPE_RATIO_MIN,
);
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;
}