Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Solve.ts
T
eomsangdonandClaude Opus 5 8daadf2d4f fix(B06): I형 집수정 배관 끝단이 마감면에 접혀 삼각형이 되던 결함 수정
관 끝단 두 꼭짓점을 마감면(I형 벽 안쪽 변) 선분 안으로 자를 때, 원지반이 벽 상단
위라 관 시작점이 벽 상단에 붙으면 위·아래 교점이 같은 끝점으로 접혀 관 단면이
삼각형으로 그려짐. 비스듬히 자른 끝단 길이는 항상 관경 이상이므로, 그보다 짧으면
접힌 것으로 보고 축 직각 마감으로 되돌림.

검증 — 공용 브라우저 B06 단면 8개 실측: 유입측 끝단 길이 0.0px → 18.7~58.4px.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 07:19:32 +09:00

579 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_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;
const cutBottom = clampToFace(bottom, face);
const cutTop = clampToFace(top, face);
// 마감면을 비스듬히 자르면 끝단 길이는 관경 이상이다. 그보다 짧으면 선분 밖으로
// 나간 교점이 한 끝점으로 접힌 것 — I형 벽이 원지반에 묻혀 관 시작점이 벽 상단에
// 붙으면 두 꼭짓점이 한 점이 되어 관이 삼각형으로 그려진다. 그럴 땐 축 직각 마감으로.
const cutSpan = Math.hypot(
cutTop.offset - cutBottom.offset,
cutTop.elevation - cutBottom.elevation,
);
if (cutSpan < diameter - 1e-6) return fallback;
return { bottom: cutBottom, top: cutTop };
};
solver.derive();
return solver;
}