이관 뒤 독립 기슭막이 벽 역할이 uphill_side로 정해져, 고른 쪽이 상단이면 유입 역할(집수정 후보·placeInletWall·다단 없음), 하단이면 유출 역할 (성토부선+buildExtrasAt)을 타서 좌우 동작이 갈렸다. 새 기하 코드를 만들지 않고 이미 있는 유출 경로 인자를 유입 쪽에도 넘겨 재사용한다. - Const: revetHeightLimit 콘크리트 4.0 → 5.0 (materialLimit과 통일) - Geom: hidden_pipe면 basinReason null, 유입 배치에 requireCrossing 전달, basinExtras 채널을 유입 벽 성토부선·다단으로 사용, inletFill 생략 - Solve: placeInletWall이 requireCrossing을 통과시킨다(기본 false 유지) - Wire: 링크 카드에서 hidden_pipe면 유입측 다단·성토부선을 벽 연장에 맞춰 유지 - Panel/View_Structure: hiddenPipe deps 추가 — 집수정 형식 숨김, 유입 벽에도 단 수 행 표시 - Culvert: hidden_pipe의 basinFill 툴팁 이름을 "유입부"로 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
569 lines
23 KiB
TypeScript
569 lines
23 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_Culvert_Solve.ts
|
||
* 배수관 세트 기하의 **보조 풀이** — 보간기·직선 교점·사면 끝 탐색·성토측 구조물
|
||
* 자리 풀이. 본체(`_Cross_Culvert_Geom.ts`)에서 분리했다(700줄 제한).
|
||
* ========================================================================== */
|
||
|
||
import type { SectionSample } from "./B06_Section_Api_Fetch";
|
||
import {
|
||
FILL_MIN_RISE_M,
|
||
FILL_SLOPE_MAX_LENGTH_M,
|
||
PIPE_CONNECT_GRADE,
|
||
FILL_SLOPE_RATIO_MIN,
|
||
REVET_HEIGHT_STEP_M,
|
||
REVET_LEAN_RATIO,
|
||
REVET_THICKNESS_M,
|
||
REVET_TRAP_TOP_M,
|
||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import type {
|
||
EndFace,
|
||
OffsetPoint,
|
||
PipeEnd,
|
||
WallAdjust,
|
||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||
|
||
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
|
||
export function fillWallBaseWidth(height: number): number {
|
||
return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
|
||
}
|
||
|
||
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
|
||
export function groundInterpolator(samples: SectionSample[]): ((offset: number) => number) | null {
|
||
const ground = samples
|
||
.filter((s) => s.valid !== false && s.elevation_m !== null && Number.isFinite(s.elevation_m))
|
||
.map((s) => ({ offset: s.offset_m ?? 0, elevation: s.elevation_m as number }))
|
||
.sort((a, b) => a.offset - b.offset);
|
||
if (!ground.length) return null;
|
||
return (offset: number): number => {
|
||
if (offset <= ground[0].offset) return ground[0].elevation;
|
||
const last = ground[ground.length - 1];
|
||
if (offset >= last.offset) return last.elevation;
|
||
for (let i = 1; i < ground.length; i += 1) {
|
||
if (offset > ground[i].offset) continue;
|
||
const a = ground[i - 1];
|
||
const b = ground[i];
|
||
const span = b.offset - a.offset;
|
||
if (span <= 0) return b.elevation;
|
||
return a.elevation + (b.elevation - a.elevation) * ((offset - a.offset) / span);
|
||
}
|
||
return last.elevation;
|
||
};
|
||
}
|
||
|
||
/** 설계선 보간기(offset 오름차순, 범위 밖 클램프). 설계선이 없으면 null. */
|
||
export function designInterpolator(
|
||
line: Array<{ offset_m: number; elevation_m: number }> | undefined,
|
||
): ((offset: number) => number) | null {
|
||
if (!line || line.length < 2) return null;
|
||
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
|
||
return (offset: number): number => {
|
||
if (offset <= sorted[0].offset_m) return sorted[0].elevation_m;
|
||
const last = sorted[sorted.length - 1];
|
||
if (offset >= last.offset_m) return last.elevation_m;
|
||
for (let i = 1; i < sorted.length; i += 1) {
|
||
if (offset > sorted[i].offset_m) continue;
|
||
const a = sorted[i - 1];
|
||
const b = sorted[i];
|
||
const span = b.offset_m - a.offset_m;
|
||
if (span <= 0) return b.elevation_m;
|
||
return a.elevation_m + (b.elevation_m - a.elevation_m) * ((offset - a.offset_m) / span);
|
||
}
|
||
return last.elevation_m;
|
||
};
|
||
}
|
||
|
||
/** 두 직선(점 + 방향)의 교점. 거의 나란하면 null. */
|
||
export function intersect(
|
||
p: OffsetPoint,
|
||
d: { offset: number; elevation: number },
|
||
q: OffsetPoint,
|
||
u: { offset: number; elevation: number },
|
||
): OffsetPoint | null {
|
||
const cross = u.offset * d.elevation - u.elevation * d.offset;
|
||
if (Math.abs(cross) < 1e-9) return null;
|
||
const t = ((p.offset - q.offset) * d.elevation - (p.elevation - q.elevation) * d.offset) / cross;
|
||
return { offset: q.offset + u.offset * t, elevation: q.elevation + u.elevation * t };
|
||
}
|
||
|
||
/** 성토사면 길이 한계(m) — **법정**: 별표2 Ⅰ.2.차.(3) "성토사면 길이 5m 이내, 초과 시
|
||
* 옹벽·석축 등 구조물 설치 의무"(지식DB 성토_비탈면.md §2). 유출 벽 자리의 바깥 한계. */
|
||
|
||
/** 관 축과 벽 전면이 거의 나란하면 교점이 멀리 발산한다 — 이 거리를 넘으면 축 직각 마감. */
|
||
export const STRAY_LIMIT_M = 2.5;
|
||
|
||
/** 노견에서 바깥으로 훑어 설계선(사면)이 지반과 만나는 첫 지점(사면 끝)을 찾는다.
|
||
* 5m는 자리 한계가 아니다 — 사면이 5m 이상이면 **기슭막이가 의무**라는 뜻일 뿐이라
|
||
* 여기서 끊지 않는다(2026-08-21 사용자 정정). */
|
||
export function slopeToeOffset(
|
||
designAt: (offset: number) => number,
|
||
groundAt: (offset: number) => number,
|
||
startOffset: number,
|
||
limitOffset: number,
|
||
): number {
|
||
const stepCount = 120;
|
||
const step = (limitOffset - startOffset) / stepCount;
|
||
if (!Number.isFinite(step) || step === 0) return limitOffset;
|
||
let previousDiff = designAt(startOffset) - groundAt(startOffset);
|
||
let previousElevation = designAt(startOffset);
|
||
let slopeLength = 0;
|
||
// 자리 = **성토선이 지반과 만나는 점**과 **사면길이 5m 지점** 중 안쪽. 사면이 5m를
|
||
// 넘으면 기슭막이가 있어야 한다는 규정(성토_비탈면.md §2)은 곧 기슭막이가 사면을
|
||
// 5m에서 끊어 준다는 뜻이다. 교차점이 5m 안이면 거기가 자연스러운 사면 끝이다.
|
||
// 더 밖으로 두고 싶으면 조정창으로 민다(2026-08-21 사용자 확정).
|
||
for (let i = 1; i <= stepCount; i += 1) {
|
||
const offset = startOffset + step * i;
|
||
const elevation = designAt(offset);
|
||
slopeLength += Math.hypot(step, elevation - previousElevation);
|
||
previousElevation = elevation;
|
||
const diff = elevation - groundAt(offset);
|
||
if (previousDiff !== 0 && Math.sign(diff) !== Math.sign(previousDiff)) return offset;
|
||
if (Math.abs(diff) < 0.02) return offset;
|
||
if (slopeLength >= FILL_SLOPE_MAX_LENGTH_M) return offset;
|
||
previousDiff = diff;
|
||
}
|
||
return limitOffset;
|
||
}
|
||
|
||
/** 노견 → (노폭 연장) → 벽 이음선 상단점 성토 사면 구간과 그 제원. */
|
||
export interface FillSlopeSegment {
|
||
ratio: number;
|
||
lengthM: number;
|
||
clamped: boolean;
|
||
fillHeightM: number;
|
||
/** 노폭 연장량(m, 편측). 벽이 1:1.2 사면이 닿는 자리보다 밖에 있으면 그만큼 늘린다. */
|
||
wideningM: number;
|
||
/** 그릴 폴리라인 — [원 노견, (연장된 노견), 벽 이음선 상단점]. */
|
||
points: OffsetPoint[];
|
||
}
|
||
|
||
/**
|
||
* 벽이 선 자리에서 성토 사면 구간을 만든다. 물매는 **1:1.2 고정**(수평 1.2 : 수직 1,
|
||
* 2026-08-21 사용자 확정)이고, 벽이 그 사면이 닿는 자리보다 밖에 있으면 부족한 만큼
|
||
* **노폭을 늘린다** — 물매를 완만하게 바꾸지 않는다.
|
||
*
|
||
* 벽 자리가 다 굳은 뒤에 불러야 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 잡으면
|
||
* 접점이 다시 어긋난다.
|
||
*/
|
||
export function fillSlopeOf(
|
||
wall: { role: "inlet" | "outlet" | "extra"; topJoint: OffsetPoint; outward: number },
|
||
edge: { offset_m: number; elevation_m: number },
|
||
): FillSlopeSegment {
|
||
const to = wall.topJoint;
|
||
const rise = edge.elevation_m - to.elevation;
|
||
const edgePoint: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m };
|
||
if (!(rise > 1e-6)) {
|
||
return {
|
||
ratio: FILL_SLOPE_RATIO_MIN,
|
||
lengthM: 0,
|
||
clamped: false,
|
||
fillHeightM: Math.max(rise, 0),
|
||
wideningM: 0,
|
||
points: [edgePoint, to],
|
||
};
|
||
}
|
||
// 1:1.2 사면이 벽 상단에 닿으려면 노견이 여기까지 나와 있어야 한다.
|
||
const neededEdgeOffset = to.offset - wall.outward * FILL_SLOPE_RATIO_MIN * rise;
|
||
const widening = Math.max(0, (neededEdgeOffset - edge.offset_m) * wall.outward);
|
||
const startOffset = edge.offset_m + wall.outward * widening;
|
||
const points: OffsetPoint[] = [edgePoint];
|
||
if (widening > 1e-6) points.push({ offset: startOffset, elevation: edge.elevation_m });
|
||
points.push(to);
|
||
const run = Math.abs(to.offset - startOffset);
|
||
return {
|
||
ratio: run / rise,
|
||
lengthM: Math.hypot(run, rise),
|
||
// 벽이 안쪽으로 들어와 1:1.2보다 급해진 경우만 경고한다(연장은 정상 동작).
|
||
clamped: run / rise < FILL_SLOPE_RATIO_MIN - 1e-6,
|
||
fillHeightM: rise,
|
||
wideningM: widening,
|
||
points,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 관 절단 길이(m) — 끝이 기운 벽 전면으로 잘려 **상단·하단 길이가 다르다**.
|
||
* 표기·정수 맞춤은 **긴 변** 기준이다(2026-08-21 사용자 확정).
|
||
*/
|
||
export function cutLength(corners: { inlet: PipeEnd; outlet: PipeEnd }): number {
|
||
return Math.max(
|
||
Math.hypot(
|
||
corners.outlet.bottom.offset - corners.inlet.bottom.offset,
|
||
corners.outlet.bottom.elevation - corners.inlet.bottom.elevation,
|
||
),
|
||
Math.hypot(
|
||
corners.outlet.top.offset - corners.inlet.top.offset,
|
||
corners.outlet.top.elevation - corners.inlet.top.elevation,
|
||
),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 유출 벽을 그 자리에 세웠을 때의 성토 사면(물매 1:n·사면길이)을 재는 함수를 만든다.
|
||
* 관 길이 정수 맞춤이 벽을 옮겨도 되는 자리인지 판정하는 데 쓴다.
|
||
*/
|
||
export function outletSlopeFactory(
|
||
info: { edge: { offset_m: number; elevation_m: number }; outward: number },
|
||
invertAt: (offset: number) => number,
|
||
wallHeight: number,
|
||
): (offset: number, thickness: number) => { ratio: number; lengthM: number } {
|
||
return (offset, thickness) => {
|
||
const joint = offset + info.outward * (thickness / 2);
|
||
const rise = info.edge.elevation_m - (invertAt(offset) + wallHeight);
|
||
const run = Math.abs(joint - info.edge.offset_m);
|
||
return {
|
||
ratio: rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY,
|
||
lengthM: Math.hypot(run, Math.max(rise, 0)),
|
||
};
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 손으로 민 벽 자리 한계 — 안쪽 당김은 **1:1.2가 성립하는 가장 안쪽 자리**(innerLimit
|
||
* = 자동 자리)에서 멈춘다(2026-08-22 사용자 확정 — 우선순위: 배관 연장 > 높이 증가 >
|
||
* 바닥 띄움. 당겨서 물매가 깨질 바에는 관을 길게 유지한다). 1:1.2 자리가 아예 없는
|
||
* 지형 폴백에서는 innerLimit이 노견까지 내려오고, 그때만 높이 증가·바닥 띄움
|
||
* (`solveWallVertical`)이 발동한다. 바깥 밀기는 막지 않는다(노폭 연장으로 흡수).
|
||
*/
|
||
export function clampWallOffset(
|
||
wantedOffset: number,
|
||
innerLimitOffset: number,
|
||
outward: number,
|
||
): number {
|
||
return (wantedOffset - innerLimitOffset) * outward < 0 ? innerLimitOffset : wantedOffset;
|
||
}
|
||
|
||
/**
|
||
* 기슭막이 **자동 자리 = 노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정).
|
||
* 노견 끝에서 바깥으로 훑어, 표준 높이 벽으로 ① 성토고 하한(FILL_MIN_RISE_M)과
|
||
* ② 물매 1:1.2 이상(급하지 않음)을 처음 만족하는 자리를 돌려준다 — 그 자리가
|
||
* 물매 1:1.2가 딱 성립하는 가장 안쪽 자리라 노폭 연장이 0에 수렴한다.
|
||
* (관 m단위 올림 잔여가 벽을 바깥으로 밀면 그만큼만 노견이 조정된다.)
|
||
*
|
||
* 사면 끝(toeOffset) **밖**의 자리는 사면 끝에 성토고가 서 있는데 물매만 급한 경우
|
||
* (규정 위반 교정)만 인정한다 — 성토가 얕아 자리 자체가 안 서는 지형에서 절벽 너머로
|
||
* 폭주하는 것을 막는다(계획고 인하 스윕에서 노폭 연장 11m 관측). 못 찾으면 null —
|
||
* 호출부가 사면 끝으로 폴백한다.
|
||
*/
|
||
export function minShoulderWallOffset(
|
||
edge: { offset_m: number; elevation_m: number },
|
||
outward: number,
|
||
height: number,
|
||
baseAt: (offset: number) => number,
|
||
toeOffset: number,
|
||
limitOffset: number,
|
||
): number | null {
|
||
const span = (limitOffset - edge.offset_m) * outward;
|
||
if (!(span > 0)) return null;
|
||
const steps = 240;
|
||
for (let i = 1; i <= steps; i += 1) {
|
||
const offset = edge.offset_m + outward * ((span * i) / steps);
|
||
const rise = edge.elevation_m - (baseAt(offset) + height);
|
||
if (rise < FILL_MIN_RISE_M) continue;
|
||
const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||
if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue;
|
||
if ((offset - toeOffset) * outward > 1e-6) {
|
||
const toeRise = edge.elevation_m - (baseAt(toeOffset) + height);
|
||
if (toeRise < FILL_MIN_RISE_M) return null; // 성토 얕음 — 사면 끝 폴백
|
||
}
|
||
return offset;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 벽의 수직 제원 — 높이·바닥 표고·바닥이 원지반에서 뜬 간격. */
|
||
export interface WallVertical {
|
||
height: number;
|
||
baseElevation: number;
|
||
/** 바닥~원지반 간격(m). 0이면 지반 위에 선다. 뜬 공간은 별도 구조물 예정. */
|
||
floatGapM: number;
|
||
}
|
||
|
||
/**
|
||
* 벽 자리에 맞는 **수직 제원**을 푼다(2026-08-22 사용자 확정 — 최소 노견 유지 규칙).
|
||
* 벽 상단(이음선 상단점)은 기본 노견에서 내려오는 **1:1.2 사면선 위**에 놓여야 한다.
|
||
* ① 자리가 안쪽이라 표준 높이(관경+여유고)로 사면선에 못 닿으면 → 높이를 키운다.
|
||
* ② 형태별 높이 한계(찰 3.0/메 2.0)에 걸리면 → 바닥을 원지반에서 띄운다
|
||
* (뜬 공간은 별도 지지 구조물 — 추가 예정).
|
||
* ③ 자리가 바깥이라 사면선이 표준 높이 아래로 지나면 → 표준 높이 유지, 모자란
|
||
* 물매는 `fillSlopeOf`가 노폭 연장으로 맞춘다(기존 규칙).
|
||
* 높이는 0.1m 눈금 올림(2026-08-21 확정)이라 상단이 사면선보다 반 눈금쯤 높을 수 있다.
|
||
*/
|
||
export function solveWallVertical(
|
||
anchorOffset: number,
|
||
edge: { offset_m: number; elevation_m: number },
|
||
outward: number,
|
||
groundBase: number,
|
||
minHeight: number,
|
||
limitHeight: number,
|
||
): WallVertical {
|
||
const gridUp = (value: number): number =>
|
||
Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
|
||
let height = minHeight;
|
||
let desiredTop = groundBase + minHeight;
|
||
for (let i = 0; i < 4; i += 1) {
|
||
const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
|
||
const run = Math.max(0, (joint - edge.offset_m) * outward);
|
||
desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN;
|
||
const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight);
|
||
if (Math.abs(next - height) < 1e-9) {
|
||
height = next;
|
||
break;
|
||
}
|
||
height = next;
|
||
}
|
||
const floatGap = Math.max(0, desiredTop - limitHeight - groundBase);
|
||
return { height, baseElevation: groundBase + floatGap, floatGapM: floatGap };
|
||
}
|
||
|
||
/** 교점을 마감면 **선분 안**으로 자른다 — 선분 밖(벽 하단 아래 등)으로 새지 않게. */
|
||
export function clampToFace(
|
||
point: OffsetPoint,
|
||
face: { base: OffsetPoint; direction: { offset: number; elevation: number } },
|
||
): OffsetPoint {
|
||
const dot =
|
||
(point.offset - face.base.offset) * face.direction.offset +
|
||
(point.elevation - face.base.elevation) * face.direction.elevation;
|
||
const len2 =
|
||
face.direction.offset * face.direction.offset +
|
||
face.direction.elevation * face.direction.elevation;
|
||
const t = len2 > 1e-12 ? Math.min(Math.max(dot / len2, 0), 1) : 0;
|
||
return {
|
||
offset: face.base.offset + face.direction.offset * t,
|
||
elevation: face.base.elevation + face.direction.elevation * t,
|
||
};
|
||
}
|
||
|
||
/** 설계선을 따라 [from → to] 구간의 경사길이(m)를 잰다 — 성토사면 길이 측정용. */
|
||
export function slopeLengthAlong(
|
||
designAt: (offset: number) => number,
|
||
from: number,
|
||
to: number,
|
||
): number {
|
||
const steps = 60;
|
||
let length = 0;
|
||
let prevElev = designAt(from);
|
||
for (let i = 1; i <= steps; i += 1) {
|
||
const o = from + ((to - from) * i) / steps;
|
||
const e = designAt(o);
|
||
length += Math.hypot((to - from) / steps, e - prevElev);
|
||
prevElev = e;
|
||
}
|
||
return length;
|
||
}
|
||
|
||
/** 배관 기슭막이 4축 배치 결과 — 좌우 x·상하 d(적용값)와 확정 자리. */
|
||
export interface PipeWallPlacement {
|
||
x: number;
|
||
d: number;
|
||
topElevation: number;
|
||
anchorOffset: number;
|
||
invert: number;
|
||
}
|
||
|
||
/**
|
||
* 배관 기슭막이 4축 배치(2026-08-22 확정 규칙 묶음) — 유입·유출 공용.
|
||
* · 기준(2026-08-23 사용자 확정): autoOffset·baseElevation0 = **성토선 길이 0
|
||
* 지점**(벽 상단 = 노견)의 자리·invert. d는 그 점 기준 절대값(null = 기본
|
||
* defaultD, 경사길이 4m 지점), 위 한계 = 성토선 0 지점(d ≥ 0)·invert 상한.
|
||
* · x = 노견 연장 평행이동(안쪽 금지).
|
||
* · requireCrossing(유출): 매몰인데 0도 절토선이 원지반과 다시 못 만나는 자리는
|
||
* 금지 — 위쪽으로 0.05m씩 되돌린다(상하 먼저, 다음 좌우).
|
||
*/
|
||
export function placePipeWall(input: {
|
||
/** 성토선 0 지점(벽 상단 = 노견)의 벽 자리(하단선 중점 offset). */
|
||
autoOffset: number;
|
||
outward: number;
|
||
height: number;
|
||
/** 성토선 0 지점의 invert(= 노견 표고 − 높이). */
|
||
baseElevation0: number;
|
||
edgeElevation: number;
|
||
/** invert 상한 — 유입: 노면−관경−토피, 유출: 유입 invert(역경사 방지). */
|
||
invertCap: number;
|
||
adjust: WallAdjust;
|
||
/** d 미지정(null) 시의 기본 자리 — 성토사면 경사길이 4m 지점의 수평 성분. */
|
||
defaultD: number;
|
||
groundAt: (offset: number) => number;
|
||
limitOffset: number;
|
||
requireCrossing: boolean;
|
||
}): PipeWallPlacement {
|
||
const top0 = input.baseElevation0 + input.height;
|
||
const dLow = Math.max(
|
||
-FILL_SLOPE_RATIO_MIN * (input.edgeElevation - top0),
|
||
-FILL_SLOPE_RATIO_MIN * (input.invertCap - (top0 - input.height)),
|
||
);
|
||
let d = Math.max(input.adjust.d ?? input.defaultD, Math.ceil(dLow * 10) / 10);
|
||
let x = Math.max(input.adjust.x, 0);
|
||
if (input.requireCrossing) {
|
||
const placeable = (anchorOffset: number, invertE: number): boolean =>
|
||
invertE >= input.groundAt(anchorOffset) - 0.01 ||
|
||
slopedCrossing(
|
||
{ offset: anchorOffset, elevation: invertE },
|
||
input.outward,
|
||
-PIPE_CONNECT_GRADE,
|
||
input.groundAt,
|
||
input.limitOffset,
|
||
) !== null;
|
||
for (let pass = 0; pass < 400; pass += 1) {
|
||
const anchorOffset = input.autoOffset + input.outward * (x + d);
|
||
if (placeable(anchorOffset, top0 - d / FILL_SLOPE_RATIO_MIN - input.height)) break;
|
||
if (Math.abs(d) > 1e-9) d = d > 0 ? Math.max(0, d - 0.05) : Math.min(0, d + 0.05);
|
||
else if (x > 1e-9) x = Math.max(0, x - 0.05);
|
||
else break;
|
||
}
|
||
d = Math.round(d * 10) / 10;
|
||
x = Math.round(x * 10) / 10;
|
||
}
|
||
const topElevation = top0 - d / FILL_SLOPE_RATIO_MIN;
|
||
return {
|
||
x,
|
||
d,
|
||
topElevation,
|
||
anchorOffset: input.autoOffset + input.outward * (x + d),
|
||
invert: topElevation - input.height,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 유입 invert를 그 높이로 내렸을 때 **유출 벽이 성립하는가**(2026-08-22 사용자 확정).
|
||
* 유입이 유출보다 높아야 하므로 유입을 내리면 유출도 따라 내려간다 — 그런데 유출이
|
||
* 원지반에 묻히고 3도 절토선이 지반과 다시 만나지 못하면 그 자리는 둘 수 없다.
|
||
* 이 경우 **유입도 더 내려가면 안 된다**(종전에는 유입만 내려가 유출이 깨졌다).
|
||
*/
|
||
export function outletReceivable(input: {
|
||
inletElevation: number;
|
||
edge: { offset_m: number; elevation_m: number };
|
||
outward: number;
|
||
height: number;
|
||
toeOffset: number;
|
||
limitOffset: number;
|
||
groundAt: (offset: number) => number;
|
||
}): boolean {
|
||
const invertAt = (offset: number): number =>
|
||
Math.min(input.groundAt(offset), input.inletElevation);
|
||
const auto =
|
||
minShoulderWallOffset(
|
||
input.edge,
|
||
input.outward,
|
||
input.height,
|
||
invertAt,
|
||
input.toeOffset,
|
||
input.limitOffset,
|
||
) ?? input.toeOffset;
|
||
const invert = invertAt(auto);
|
||
if (invert >= input.groundAt(auto) - 0.01) return true;
|
||
return (
|
||
slopedCrossing(
|
||
{ offset: auto, elevation: invert },
|
||
input.outward,
|
||
-PIPE_CONNECT_GRADE,
|
||
input.groundAt,
|
||
input.limitOffset,
|
||
) !== null
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 유입 기슭막이 4축 배치 — placePipeWall에 **유출 수용 가능성 가드**를 얹은 것.
|
||
* 유입을 내리면 유출도 따라 내려가므로(유입 > 유출 조건), 유출이 못 받는 높이면
|
||
* 유입도 그만큼 못 내려간다(2026-08-22 사용자 확정).
|
||
*/
|
||
export function placeInletWall(input: {
|
||
autoOffset: number;
|
||
outward: number;
|
||
height: number;
|
||
baseElevation0: number;
|
||
edgeElevation: number;
|
||
invertCap: number;
|
||
adjust: WallAdjust;
|
||
defaultD: number;
|
||
groundAt: (offset: number) => number;
|
||
limitOffset: number;
|
||
/** 유출 쪽 수용 검사 인자 — 없으면 가드 없이 배치한다. */
|
||
outletGuard?: {
|
||
edge: { offset_m: number; elevation_m: number };
|
||
outward: number;
|
||
height: number;
|
||
toeOffset: number;
|
||
limitOffset: number;
|
||
};
|
||
/** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */
|
||
requireCrossing?: boolean;
|
||
}): PipeWallPlacement {
|
||
const place = (adjust: WallAdjust): PipeWallPlacement =>
|
||
placePipeWall({ ...input, adjust, requireCrossing: input.requireCrossing === true });
|
||
let placed = place(input.adjust);
|
||
const guard = input.outletGuard;
|
||
if (!guard) return placed;
|
||
const receivable = (invert: number): boolean =>
|
||
outletReceivable({ inletElevation: invert, groundAt: input.groundAt, ...guard });
|
||
for (let pass = 0; pass < 400 && placed.d > 1e-9 && !receivable(placed.invert); pass += 1) {
|
||
placed = place({
|
||
...input.adjust,
|
||
d: Math.max(0, Math.round((placed.d - 0.05) * 100) / 100),
|
||
});
|
||
}
|
||
return placed;
|
||
}
|
||
|
||
/** 관 축·법선·양 끝단 꼭짓점을 한 번에 푸는 도구(본체 700줄 제한 — 2026-08-22). */
|
||
export interface PipeAxisSolver {
|
||
axis: OffsetPoint;
|
||
normal: OffsetPoint;
|
||
/** 벽이 옮겨진 뒤 축을 다시 유도한다. */
|
||
derive: () => void;
|
||
/** 끝단면(구조물 계류측 변)으로 자른 꼭짓점 — 면이 없거나 크게 벗어나면 폴백. */
|
||
corners: (role: "inlet" | "outlet", endPoint: OffsetPoint) => PipeEnd;
|
||
}
|
||
|
||
export function pipeAxisSolver(
|
||
pipeStart: OffsetPoint,
|
||
outlet: OffsetPoint,
|
||
diameter: number,
|
||
endFaces: { inlet: EndFace | null; outlet: EndFace | null },
|
||
): PipeAxisSolver {
|
||
const solver: PipeAxisSolver = {
|
||
axis: { offset: 1, elevation: 0 },
|
||
normal: { offset: 0, elevation: 1 },
|
||
derive: () => undefined,
|
||
corners: () => ({ bottom: pipeStart, top: pipeStart }),
|
||
};
|
||
let topAnchor: OffsetPoint = pipeStart;
|
||
solver.derive = (): void => {
|
||
const runP = outlet.offset - pipeStart.offset;
|
||
const riseP = outlet.elevation - pipeStart.elevation;
|
||
const axisLength = Math.hypot(runP, riseP) || 1;
|
||
solver.axis = { offset: runP / axisLength, elevation: riseP / axisLength };
|
||
const normal = { offset: -solver.axis.elevation, elevation: solver.axis.offset };
|
||
solver.normal =
|
||
normal.elevation < 0 ? { offset: -normal.offset, elevation: -normal.elevation } : normal;
|
||
topAnchor = {
|
||
offset: pipeStart.offset + solver.normal.offset * diameter,
|
||
elevation: pipeStart.elevation + solver.normal.elevation * diameter,
|
||
};
|
||
};
|
||
solver.corners = (role, endPoint): PipeEnd => {
|
||
const fallback: PipeEnd = {
|
||
bottom: endPoint,
|
||
top: {
|
||
offset: endPoint.offset + solver.normal.offset * diameter,
|
||
elevation: endPoint.elevation + solver.normal.elevation * diameter,
|
||
},
|
||
};
|
||
const face = endFaces[role];
|
||
if (!face) return fallback;
|
||
const bottom = intersect(face.base, face.direction, pipeStart, solver.axis);
|
||
const top = intersect(face.base, face.direction, topAnchor, solver.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) };
|
||
};
|
||
solver.derive();
|
||
return solver;
|
||
}
|