- 관 유도 접속선을 0도 → **3도**로: 유입측은 계류 쪽 오름(물이 관으로), 유출측 매몰 절토선은 계류 쪽 내림(물이 밖으로). 집수정 I형 되메움선도 동일 - 모든 유도선·성토부선 끝점을 격자점이 아니라 **선형 보간 교차점**에 붙여 선이 원지반을 미세하게 벗어나던 문제 해소(2026-08-22 사용자 지적) - 카드 자동 줌아웃: **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는 지점(+0.5m)이 표시 반폭 밖이면 그 카드만 반폭을 넓혀 그린다. 보유 샘플 범위를 넘는 경우는 기존 전역 반폭 재생성 경로(applyPanelToAll)가 담당 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
289 lines
13 KiB
TypeScript
289 lines
13 KiB
TypeScript
/* =============================================================================
|
||
* B06_Section_UI_Cross_Culvert_Basin.ts
|
||
* 유입 **집수정** 단면 구성 — 기하 본체(`_Cross_Culvert_Geom.ts`)에서 분리(700줄 제한).
|
||
*
|
||
* 집수정은 기슭막이와 달리 **물을 모으려고 만든 구조물**이다(2026-08-22 사용자 확정):
|
||
* · I형(공유벽뿐): 배관은 원지반 자리 유지. 기본 높이(관경+여유)로 노견 끝 접점이
|
||
* 안 되면 벽을 노견 표고까지 키운다.
|
||
* · ㄴ(L)형(= I형 + 바닥판, 기본값): 내부 높이 1.2m(임시값) 등 사이즈를 유지한 채
|
||
* 통째로 노견 끝점에 일치한다 — 바닥(=관 유입 invert)이 따라 올라 관 물매가 바뀐다.
|
||
* · ㄷ(U)형: ㄴ형 + 반대측(도로측) 막음벽(I형 수직반전).
|
||
* 부재(벽·바닥판) 두께 0.2m(2026-08-22 사용자 지정). 벽 자리 = 상단 도로측 꼭짓점이
|
||
* **노견 끝 지점**에 닿는 자리(우측 집수정이면 상단 좌측점).
|
||
* ========================================================================== */
|
||
|
||
import {
|
||
PIPE_CONNECT_GRADE,
|
||
BASIN_INNER_HEIGHT_M,
|
||
BASIN_MEMBER_THICKNESS_M,
|
||
REVET_FREEBOARD_M,
|
||
REVET_LEAN_RATIO,
|
||
} from "./B06_Section_UI_Cross_Culvert_Const";
|
||
import { minShoulderWallOffset, slopeToeOffset } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||
import type {
|
||
BasinLayout,
|
||
BasinShape,
|
||
EndFace,
|
||
OffsetPoint,
|
||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||
|
||
/** 집수정 구성 입력 — 기하 본체가 자기 상태를 명시적으로 물려 준다. */
|
||
export interface BasinBuildInput {
|
||
/** 관 유입 기준점(노견 끝 offset, invert 표고 — ㄴ·ㄷ형은 상승분 반영 후). */
|
||
anchor: OffsetPoint;
|
||
/** 계류측 방향 부호(+ = 좌). */
|
||
outward: number;
|
||
shape: BasinShape;
|
||
reason: BasinLayout["reason"];
|
||
/** 관 외경 지름(m). */
|
||
diameterM: number;
|
||
/** 이 측의 노견 끝 점 — 벽 상단 접점·I형 높이 성장 기준. */
|
||
edge: { offset_m: number; elevation_m: number };
|
||
groundAt: (offset: number) => number;
|
||
/** 표준단면 절토 경사(1:n) — 절토 계획선용. */
|
||
cutSlopeRatio: number;
|
||
}
|
||
|
||
/** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */
|
||
export interface BasinBuildResult {
|
||
basin: BasinLayout;
|
||
/** 관 끝단 마감면 = I형 벽의 안쪽(물받는 쪽) 변 복사(2026-08-22 사용자 확정). */
|
||
endFace: EndFace;
|
||
/** 관 시작점(하단 기준) — ㄴ·ㄷ형은 측벽 안쪽 변×바닥 윗면 교차점, I형은 내공 1.0m. */
|
||
pipeEnd: OffsetPoint;
|
||
/** 성토 사면선 트림 경계(벽 상단 도로측 꼭짓점)와 그 표고. */
|
||
trimOffset: number;
|
||
trimElevation: number;
|
||
}
|
||
|
||
/** 실무 내공 폭(m) — 울진 돌집수정. I형 관 시작점(노견+1.0m) 계산에도 쓴다. */
|
||
export const BASIN_INNER_WIDTH_M = 1.0;
|
||
const INNER_WIDTH_M = BASIN_INNER_WIDTH_M;
|
||
|
||
/** 집수정 주변 성토(되메움) 표면 경사(도) — 2026-08-22 사용자 지정 "일단 5도". */
|
||
const BASIN_FILL_ANGLE_DEG = 5;
|
||
|
||
/** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */
|
||
export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||
const { anchor, outward, shape, reason, diameterM, edge, groundAt, cutSlopeRatio } = input;
|
||
const memberT = BASIN_MEMBER_THICKNESS_M;
|
||
const floorThickness = memberT;
|
||
// 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값).
|
||
const wallHeight =
|
||
shape === "I"
|
||
? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation)
|
||
: BASIN_INNER_HEIGHT_M;
|
||
// 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20).
|
||
// bottomElevation을 내리면 면 기울기를 그대로 연장한다(I형 근입용).
|
||
const defaultBottom = anchor.elevation - floorThickness;
|
||
const wallOf = (
|
||
baseOffset: number,
|
||
dir: number,
|
||
bottomElevation = defaultBottom,
|
||
): OffsetPoint[] => {
|
||
const topShift = -dir * REVET_LEAN_RATIO * wallHeight;
|
||
const spanZ = wallHeight + floorThickness;
|
||
const xAt = (elevation: number): number =>
|
||
baseOffset + topShift * ((elevation - defaultBottom) / spanZ);
|
||
return [
|
||
{ offset: xAt(bottomElevation), elevation: bottomElevation },
|
||
{ offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight },
|
||
{
|
||
offset: baseOffset + topShift + dir * memberT,
|
||
elevation: anchor.elevation + wallHeight,
|
||
},
|
||
{
|
||
offset: xAt(bottomElevation) + dir * memberT,
|
||
elevation: bottomElevation,
|
||
},
|
||
];
|
||
};
|
||
// I형 벽 자리 = **상단 도로측 꼭짓점이 노견 끝 지점에 닿는다**(2026-08-22 사용자
|
||
// 확정). 벽 상단이 도로측으로 1:0.3 물러나므로 하단(base)은 그만큼 계류측 바깥.
|
||
const wallBase = anchor.offset + outward * (REVET_LEAN_RATIO * wallHeight);
|
||
// I형 벽 하단은 **원지반까지 묻는다**(2026-08-22 사용자 — 지반 아래 부재두께만큼
|
||
// 근입 후 배관을 둔다). ㄴ·ㄷ형은 바닥판이 원지반을 대체하므로 현행 유지.
|
||
const iWallBottom =
|
||
shape === "I"
|
||
? Math.min(anchor.elevation, groundAt(wallBase), groundAt(wallBase + outward * memberT)) -
|
||
floorThickness
|
||
: defaultBottom;
|
||
const parts: BasinLayout["parts"] = [
|
||
{ kind: "wall", points: wallOf(wallBase, outward, iWallBottom) },
|
||
];
|
||
const iWallPoints = parts[0].points;
|
||
// ㄴ형 = I형 + 바닥판. 바닥은 벽 바깥(계류측) 변에 맞대고 그 너머로 뻗는다
|
||
// (2026-08-20 확정). 벽이 기울어 있어 안쪽 변도 벽 바깥면 선을 그대로 따른다.
|
||
const wallOuterAt = (elevation: number): number => {
|
||
const bottom = anchor.elevation - floorThickness;
|
||
const t = (elevation - bottom) / (wallHeight + floorThickness);
|
||
return wallBase + outward * memberT - outward * REVET_LEAN_RATIO * wallHeight * t;
|
||
};
|
||
const floorTopInner = wallOuterAt(anchor.elevation);
|
||
const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness);
|
||
const floorOuter = floorTopInner + outward * (INNER_WIDTH_M + memberT);
|
||
const floorTaper = REVET_LEAN_RATIO * floorThickness;
|
||
if (shape !== "I") {
|
||
parts.push({
|
||
kind: "floor",
|
||
points: [
|
||
{ offset: floorTopInner, elevation: anchor.elevation },
|
||
{ offset: floorOuter, elevation: anchor.elevation },
|
||
{
|
||
offset: floorOuter - outward * floorTaper,
|
||
elevation: anchor.elevation - floorThickness,
|
||
},
|
||
{ offset: floorBottomInner, elevation: anchor.elevation - floorThickness },
|
||
],
|
||
});
|
||
}
|
||
// ㄷ형 = ㄴ형 + 막음벽 — I형 벽을 **바닥부 중심 기준 수직 반전**한 자리·형상
|
||
// (2026-08-22 사용자 ① — 종전에는 노견 쪽에 세워 방향이 틀렸다).
|
||
const floorCenter = (floorTopInner + floorOuter) / 2;
|
||
const mirroredWallBase = 2 * floorCenter - wallBase;
|
||
if (shape === "U") {
|
||
parts.push({ kind: "wall", points: wallOf(mirroredWallBase, -outward) });
|
||
}
|
||
// 구조물 계류측 끝(ㄴ형 = 바닥 바깥 끝 상단, ㄷ형 = 막음벽 계류측 상단 꼭짓점)과
|
||
// 원지반의 관계(2026-08-22 사용자 ①):
|
||
// · 원지반 안쪽에 박히면 → 표준단면 절토경사(1:n)로 **절토선**
|
||
// · 원지반 밖으로 나오면 → **성토(되메움)선** — 경사 5도(임시값)로 지반까지.
|
||
let basinCut: BasinLayout["cutLine"] = null;
|
||
let basinFill: BasinLayout["cutLine"] = null;
|
||
if (shape !== "I") {
|
||
const refTop =
|
||
shape === "U"
|
||
? {
|
||
offset: mirroredWallBase + outward * REVET_LEAN_RATIO * wallHeight,
|
||
elevation: anchor.elevation + wallHeight,
|
||
}
|
||
: { offset: floorOuter, elevation: anchor.elevation };
|
||
const groundHere = groundAt(refTop.offset);
|
||
if (groundHere > refTop.elevation + 0.05) {
|
||
for (let h = 0.05; h <= 20; h += 0.05) {
|
||
const probe = refTop.offset + outward * cutSlopeRatio * h;
|
||
if (groundAt(probe) <= refTop.elevation + h) {
|
||
basinCut = { from: refTop, to: { offset: probe, elevation: refTop.elevation + h } };
|
||
break;
|
||
}
|
||
}
|
||
} else if (groundHere < refTop.elevation - 0.05) {
|
||
const dropPerM = Math.tan((BASIN_FILL_ANGLE_DEG * Math.PI) / 180);
|
||
for (let run = 0.05; run <= 30; run += 0.05) {
|
||
const probe = refTop.offset + outward * run;
|
||
const elevation = refTop.elevation - dropPerM * run;
|
||
if (elevation <= groundAt(probe)) {
|
||
basinFill = { from: refTop, to: { offset: probe, elevation } };
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return {
|
||
basin: {
|
||
shape,
|
||
parts,
|
||
// 라벨 자리 = I형 벽 하단(2026-08-20 사용자) — 그리기 쪽에서 아래로 내려 붙인다.
|
||
label: {
|
||
offset: wallBase + outward * (memberT / 2),
|
||
elevation: anchor.elevation - floorThickness,
|
||
},
|
||
reason,
|
||
cutLine: basinCut,
|
||
fillLine: basinFill,
|
||
},
|
||
// 관 끝선 = I형 벽의 안쪽(물받는 쪽 = 내공측) 변 복사(2026-08-22 사용자 확정).
|
||
// 벽 폴리곤의 하단-전면(3)~상단-전면(2) 꼭짓점을 그대로 쓴다 — I형 근입 연장도 반영.
|
||
endFace: {
|
||
base: iWallPoints[3],
|
||
direction: {
|
||
offset: iWallPoints[2].offset - iWallPoints[3].offset,
|
||
elevation: iWallPoints[2].elevation - iWallPoints[3].elevation,
|
||
},
|
||
},
|
||
pipeEnd:
|
||
shape === "I"
|
||
? { offset: anchor.offset + outward * INNER_WIDTH_M, elevation: anchor.elevation }
|
||
: { offset: floorTopInner, elevation: anchor.elevation },
|
||
trimOffset: wallBase - outward * REVET_LEAN_RATIO * wallHeight,
|
||
trimElevation: anchor.elevation + wallHeight,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 조정창 드롭다운 선택지 가용성(2026-08-22 사용자 — 상황에 안 맞는 선택지 숨김):
|
||
* · 기슭막이+배관: 자동 자리의 원지반이 토피 상한(노면−관경−토피)보다 높으면 배관이
|
||
* 원지반에 묻힐 수밖에 없다 → 숨김.
|
||
* · 집수정 ㄴ·ㄷ형: ㄴ형으로 가정해 세워 봤을 때 구조물이 원지반으로 밀고 들어가지
|
||
* 않으면(절토선이 안 생기면) → 숨김.
|
||
*/
|
||
export function inletChoiceAvailability(input: {
|
||
designAt: ((offset: number) => number) | null;
|
||
groundAt: (offset: number) => number;
|
||
edge: { offset_m: number; elevation_m: number };
|
||
outward: number;
|
||
limitOffset: number;
|
||
wallHeight: number;
|
||
invertCapM: number;
|
||
diameterM: number;
|
||
cutSlopeRatio: number;
|
||
}): { revetAllowed: boolean; basinLUAllowed: boolean; revetAutoOffset: number } {
|
||
const { designAt, groundAt, edge, outward, limitOffset, wallHeight, invertCapM } = input;
|
||
let revetAllowed = true;
|
||
// 기슭막이 자동 자리(노견 최소 지점 ?? 사면 끝) — 본체가 벽 배치에도 재사용한다.
|
||
let revetAutoOffset = edge.offset_m;
|
||
if (designAt) {
|
||
const baseAt = (offset: number): number => Math.min(groundAt(offset), invertCapM);
|
||
const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limitOffset);
|
||
revetAutoOffset =
|
||
minShoulderWallOffset(edge, outward, wallHeight, baseAt, toe, limitOffset) ?? toe;
|
||
revetAllowed = groundAt(revetAutoOffset) <= invertCapM + 0.02;
|
||
}
|
||
const probe = buildBasin({
|
||
anchor: { offset: edge.offset_m, elevation: edge.elevation_m - BASIN_INNER_HEIGHT_M },
|
||
outward,
|
||
shape: "L",
|
||
reason: "short",
|
||
diameterM: input.diameterM,
|
||
edge,
|
||
groundAt,
|
||
cutSlopeRatio: input.cutSlopeRatio,
|
||
});
|
||
return { revetAllowed, basinLUAllowed: probe.basin.cutLine != null, revetAutoOffset };
|
||
}
|
||
|
||
/**
|
||
* I형 집수정의 **관 하단 수평 되메움(성토)선**(2026-08-22 사용자). 관 유입 하단
|
||
* 꼭짓점이 원지반 위에 뜨면 그 표고 그대로 계류측으로 나가 원지반과 만나는 점까지
|
||
* 잇고, 그 아래를 성토로 본다. 지반 교점 절단 방식은 보고 후 철회.
|
||
*/
|
||
export function applyBasinPipeFill(
|
||
basin: BasinLayout,
|
||
corner: OffsetPoint,
|
||
outward: number,
|
||
groundAt: (offset: number) => number,
|
||
): void {
|
||
// 되메움선 물매 3도(2026-08-22 사용자) — 유입측이라 계류 쪽이 높다(물이 관으로).
|
||
if (basin.shape !== "I" || corner.elevation <= groundAt(corner.offset) + 0.02) return;
|
||
let previousGap = corner.elevation - groundAt(corner.offset); // 선 − 지반
|
||
for (let run = 0.05; run <= 30; run += 0.05) {
|
||
const probe = corner.offset + outward * run;
|
||
const elevation = corner.elevation + PIPE_CONNECT_GRADE * run;
|
||
const gap = elevation - groundAt(probe);
|
||
if (gap <= 0) {
|
||
// 끝점은 보간 교차점 — 격자점이면 지반을 미세하게 벗어난다(2026-08-22 사용자).
|
||
const fraction = previousGap > 1e-12 ? previousGap / (previousGap - gap) : 1;
|
||
const runCross = run - 0.05 * (1 - fraction);
|
||
basin.fillLine = {
|
||
from: corner,
|
||
to: {
|
||
offset: corner.offset + outward * runCross,
|
||
elevation: corner.elevation + PIPE_CONNECT_GRADE * runCross,
|
||
},
|
||
};
|
||
return;
|
||
}
|
||
previousGap = gap;
|
||
}
|
||
}
|