/* ============================================================================= * 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, ); /** 기준선(근입 위)~상단 — 도형·자리 계산은 종전대로 이 값으로 한다. * 지형이 요청 높이만큼 안 나오면 아래 스캔 뒤 **낮춰서** 바닥을 지반에 앉힌다. */ let 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; const topElevation = autoTop - appliedD; const anchorX = autoOffset + outward * appliedX; // 바닥을 **원지반에 앉힌다**(2026-09-06 사용자 지적: 연계 기슭막이가 사면과 안 맞음). // 상단은 1:1.2 성토선에 붙어 있어야 하므로 자리는 그대로 두고 **키만** 지반까지 // 맞춘다 — 지반이 낮으면 키우고(형태 한계까지), 높으면 줄인다(최소 높이까지). // 예전에는 요청 높이를 그대로 써 바닥이 최대 0.87m 떠 있었다(실측 264.06·731.31). const groundBase = groundAt(anchorX); const fitted = Math.min( Math.max(topElevation - groundBase, EXTRA_WALL_MIN_HEIGHT_M - REVET_EMBED_DEPTH_M), limit - REVET_EMBED_DEPTH_M, ); const appliedExposed = Number.isFinite(fitted) ? fitted : exposed; const base = topElevation - appliedExposed; // 근입 0.5 위 기준선 const wall = buildExtraWall( i, { offset: anchorX, elevation: base }, outward, appliedExposed, material, Math.max(0, base - groundBase), form, ); // 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을 // 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동). const blocked = Math.max(0, (requestedX ?? 0) - appliedX); if (blocked > 0.005) wall.shiftBlockedM = Math.round(blocked * 100) / 100; // 하한에 눌려 요청이 통째로 무시된 경우 — 지형이 아니라 **선반 길이**가 원인이다 // (2026-09-07 실측: d 2.6m 이면 x 하한이 3.12m 라 1.0m 요청이 아무 변화도 못 냄). // 사용자가 실제로 넣었을 때만 알린다 — 요청이 없으면(0) 하한은 그냥 기본 자리라 // 알릴 것이 없다(모든 벽에 뜨면 소리만 됨). const floor = shelfFloor(appliedD); if ((requestedX ?? 0) > 0.005 && floor > (requestedX ?? 0) + 0.005) { wall.shiftFloorM = Math.round(floor * 100) / 100; } walls.push(wall); // 되받기 — 지반에 맞춰 조정한 값이 **실제 적용 높이**다(조정창이 그대로 받아 적는다). appliedAdjusts.push({ x: appliedX, d: appliedD, h: adjust.h != null ? appliedExposed + REVET_EMBED_DEPTH_M : null, m: adjust.m, }); // 성토선: 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, ): OutletExtrasResult { return start ? buildOutletExtras({ ...rest, start }) : NO_EXTRAS; }