feat(B06): 관 접속 절·성토선 3도 물매 + 교차점 보간 + 절성토 교차 기준 자동 줌아웃

- 관 유도 접속선을 0도 → **3도**로: 유입측은 계류 쪽 오름(물이 관으로),
  유출측 매몰 절토선은 계류 쪽 내림(물이 밖으로). 집수정 I형 되메움선도 동일
- 모든 유도선·성토부선 끝점을 격자점이 아니라 **선형 보간 교차점**에 붙여
  선이 원지반을 미세하게 벗어나던 문제 해소(2026-08-22 사용자 지적)
- 카드 자동 줌아웃: **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는
  지점(+0.5m)이 표시 반폭 밖이면 그 카드만 반폭을 넓혀 그린다. 보유 샘플
  범위를 넘는 경우는 기존 전역 반폭 재생성 경로(applyPanelToAll)가 담당

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-22 15:44:10 +09:00
co-authored by Claude Opus 5
parent de78feabde
commit cd855e32d3
7 changed files with 154 additions and 38 deletions
@@ -13,6 +13,7 @@
import {
EXTRA_WALL_DEFAULT_HEIGHT_M,
PIPE_CONNECT_GRADE,
EXTRA_WALL_MIN_HEIGHT_M,
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MIN,
@@ -56,16 +57,26 @@ export interface OutletExtrasResult {
/** 수평선(0도)이 계류측으로 나가며 원지반 아래로 다시 떨어지는 첫 지점(절토 교차).
* 없으면 null — 그 자리는 매몰 상태로 둘 수 없다(2026-08-22 사용자 ③ 이동 금지). */
export function horizontalCrossing(
export function slopedCrossing(
from: OffsetPoint,
outward: number,
grade: number,
groundAt: (offset: number) => number,
limitOffset: number,
): number | null {
const span = (limitOffset - from.offset) * outward;
for (let t = 0.25; t <= span + 1e-9; t += 0.25) {
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;
if (groundAt(x) <= from.elevation) return x;
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;
}
@@ -85,33 +96,58 @@ export function inletGroundConnector(
const ground = groundAt(corner.offset);
const span = (limitOffset - corner.offset) * outward;
if (corner.elevation > ground + 0.02) {
let cross = corner.offset + outward * Math.max(span, 0);
// 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;
if (groundAt(x) >= corner.elevation) {
cross = x;
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, { offset: cross, elevation: corner.elevation }],
lengthM: Math.abs(cross - corner.offset),
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(계류 쪽이 높다) 후 표준 절토 경사.
const bend: OffsetPoint = {
offset: corner.offset + outward * 1.0,
elevation: corner.elevation,
elevation: corner.elevation + PIPE_CONNECT_GRADE * 1.0,
};
let last = bend;
const cutSpan = (limitOffset - bend.offset) * outward;
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);
last = {
offset: bend.offset + outward * tCross,
elevation: bend.elevation + tCross / Math.max(cutSlopeRatio, 0.1),
};
break;
}
last = { offset: x, elevation };
if (elevation >= groundAt(x)) break;
previousGap = gap;
}
return {
points: [corner, bend, last],
@@ -138,15 +174,28 @@ function trailingFill(
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 groundElevation = groundAt(offset);
const point: OffsetPoint = { offset, elevation: Math.max(slopeElevation, groundElevation) };
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;
if (slopeElevation <= groundElevation) break;
previousGap = gap;
}
return {
points,
@@ -337,11 +386,12 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
if (groundAt(startX) < startE + 0.05) return true;
// 매몰 — 0도 절토선이 원지반과 다시 만나면 허용(2026-08-22 ③), 아니면 이동 금지.
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
return (
horizontalCrossing(
slopedCrossing(
{ offset: startX, elevation: startE },
outward,
-PIPE_CONNECT_GRADE,
groundAt,
limitOffset,
) !== null
@@ -413,11 +463,15 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
segments.push(tail);
addable = tail.overLimit;
} else {
const cross = horizontalCrossing(src, outward, groundAt, limitOffset);
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, { offset: cross, elevation: src.elevation }],
lengthM: Math.abs(cross - src.offset),
points: [src, end],
lengthM: Math.hypot(end.offset - src.offset, end.elevation - src.elevation),
overLimit: false,
kind: "cut",
});