519 lines
23 KiB
TypeScript
519 lines
23 KiB
TypeScript
/* =============================================================================
|
||
* 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)로 지반까지 올라가는 절토선.
|
||
* 지반 교차가 안 나오는 지형은 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,
|
||
): 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: 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);
|
||
// 높이 기준 = **벽 바닥~상단**(근입 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)),
|
||
);
|
||
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;
|
||
}
|