Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts
T
eomsangdonandClaude Opus 5 11abd303e7 fix(B06): 세월교 측벽을 수직으로 세운다
증상(사용자): 세월교 횡단도 측벽이 기울어 있고 바닥판 모서리도 깎여 있다.
확인 결과 세로(수직)로 그려져야 한다.

원인: 세월교 측벽은 ㄴ형 집수정 부재를 재사용한다(2026-08-25). 그래서 집수정
규칙인 전면 1:0.3 기움과 거기서 파생된 바닥판 taper(0.3 × 두께)가 딸려 왔다.
세월교 근거가 아니라 부재를 빌려 오며 따라온 값이다.

근거: 지식DB·교본 원문에 세월교 단면 기울기 수치는 없다. 교본 그림 3-18
세월교 설치 종단도의 구체가 수직 벽이고, 사용자가 그렇게 확정했다.

고침: buildBasin에 leanRatio 인자를 두고(기본 0.3) 세월교만 0을 넘긴다.
집수정 호출부는 인자를 안 넘기므로 동작이 그대로다. 벽 상단 도로측 꼭짓점
(설계선 트림 경계)은 노견 끝 그대로라 트림·접근선 규칙도 불변이고, 바닥
길이는 날개벽 투영 연장 + 벽 두께로 유지된다. 3D는 같은 폴리곤을 스윕하므로
자동 반영된다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-29 13:22:50 +09:00

427 lines
19 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_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,
BasinAdjust,
BasinShape,
EndFace,
OffsetPoint,
InletStructureChoice,
} from "./B06_Section_UI_Cross_Culvert_Types";
export function resolveBasinChoice(
choice: InletStructureChoice,
ruleReason: BasinLayout["reason"] | null,
): { reason: BasinLayout["reason"] | null; shape: BasinShape } {
const selected = choice === "I" || choice === "L" || choice === "U";
return {
reason: choice === "revet" ? null : selected ? (ruleReason ?? "manual") : ruleReason,
// 자동(기본) 집수정은 **I형**(2026-08-23 사용자) — 종전 L형 기본은 ㄴ·ㄷ형이
// 선택지에서 숨는 지형(basinLUAllowed=false)에서 선택지 밖 구조물이 그려졌다.
shape: selected ? choice : "I",
};
}
export function basinApproachPoints(
edge: { offset_m: number; elevation_m: number },
outward: number,
adjust: BasinAdjust,
trim: OffsetPoint,
): OffsetPoint[] | null {
// 접근선은 **설계선이 벽 상단까지 따라올 경로**다. 이 선을 주지 않으면 설계선이
// 자기 성토 물매(1:1.2)로 내려갔다가 트림 표고(벽 상단)로 되튀어 올라 급경사
// 선이 남는다(2026-08-22 사용자 지적). 좌우만 옮겨도 반드시 준다 — 집수정이
// 나간 만큼 노견이 수평으로 연장되는 것이 맞는 표현이다.
if (adjust.lateralM <= 1e-6 && adjust.slopeM <= 1e-6) return null;
const points: OffsetPoint[] = [{ offset: edge.offset_m, elevation: edge.elevation_m }];
if (adjust.lateralM > 1e-6) {
points.push({
offset: edge.offset_m + outward * adjust.lateralM,
elevation: edge.elevation_m,
});
}
// 중복 꼭짓점 제거 — 수평 선반 끝과 벽 상단이 겹치면 길이 0 구간이 남는다.
const last = points[points.length - 1];
if (Math.hypot(trim.offset - last.offset, trim.elevation - last.elevation) > 1e-6) {
points.push(trim);
}
return points.length >= 2 ? points : null;
}
/** 집수정 구성 입력 — 기하 본체가 자기 상태를 명시적으로 물려 준다. */
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;
adjust: BasinAdjust;
/** 벽 두께(m). 기본 = 집수정 부재 두께. 세월교 측벽이 다른 두께를 쓸 때 넘긴다. */
memberThicknessM?: number;
/** 바닥판 두께(m). 기본 = 벽 두께. 세월교는 교본 물받이 최소 0.3m를 쓴다. */
floorThicknessM?: number;
/**
* 벽 기움(1:n의 n). 기본 = 집수정 규칙 0.3. **세월교는 0**(수직) — 기움은 집수정
* 부재를 재사용하며 딸려 온 값이고, 교본 그림 3-18 세월교 단면은 수직 벽이다
* (2026-08-30 사용자 확정: 집수정은 그대로 두고 세월교만 세운다).
*/
leanRatio?: number;
}
/** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */
export interface BasinBuildResult {
basin: BasinLayout;
/** 관 끝단 마감면 = I형 벽의 안쪽(물받는 쪽) 변 복사(2026-08-22 사용자 확정). */
endFace: EndFace;
/** 관 시작점(하단 기준) — ㄴ·ㄷ형은 측벽 안쪽 변×바닥 윗면 교차점, I형은 내공 1.0m. */
pipeEnd: OffsetPoint;
/** 성토 사면선 트림 경계(벽 상단 도로측 꼭짓점)와 그 표고. */
trimOffset: number;
trimElevation: number;
/**
* 계류측 성토부선 시작점(2026-08-22 사용자 확정 — 유출측과 같은 체계).
* ㄴ·ㄷ형에서 구조물 계류측 끝이 **원지반 위**로 나올 때만 준다. 그 자리에서
* 1:1.2 성토부선을 긋고 5m를 넘으면 다단 기슭막이를 둘 수 있다. 원지반 안으로
* 박히면 종전대로 절토선(cutLine)이고, I형은 관 하단 되메움선이 맡는다.
*/
fillStart: OffsetPoint | null;
/** 구조물 바닥 표고 — 다단 1단이 이 아래를 뚫지 못하게 하는 기준. */
fillBottomElevation: number;
}
/** 실무 내공 폭(m) — 울진 돌집수정. I형 관 시작점(노견+1.0m) 계산에도 쓴다. */
export const BASIN_INNER_WIDTH_M = 1.0;
/** 집수정 단면을 만든다. 형태별 규칙은 파일 머리말 참조. */
export function buildBasin(input: BasinBuildInput): BasinBuildResult {
const {
anchor: baseAnchor,
outward,
shape,
reason,
diameterM,
edge,
groundAt,
cutSlopeRatio,
adjust,
} = input;
/** 이 부재의 기움 — 집수정 0.3, 세월교 0(수직). */
const lean = input.leanRatio ?? REVET_LEAN_RATIO;
const movedAnchor = {
offset: baseAnchor.offset + outward * (adjust.lateralM + adjust.slopeM),
elevation: baseAnchor.elevation - adjust.slopeM / 1.2,
};
const anchor = movedAnchor;
const memberT = input.memberThicknessM ?? BASIN_MEMBER_THICKNESS_M;
const floorThickness = input.floorThicknessM ?? memberT;
// 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값).
const wallHeight =
shape === "I"
? Math.max(diameterM + REVET_FREEBOARD_M, edge.elevation_m - anchor.elevation)
: adjust.innerHeightM;
// 기움은 **수직 기준 반전** — 상단이 도로측(내공 쪽)으로 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 * lean * 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 * (lean * 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;
const iGroundIntersection = (): OffsetPoint => {
const low = iWallPoints[3];
const high = iWallPoints[2];
let lo = 0;
let hi = 1;
for (let pass = 0; pass < 32; pass += 1) {
const mid = (lo + hi) / 2;
const point = {
offset: low.offset + (high.offset - low.offset) * mid,
elevation: low.elevation + (high.elevation - low.elevation) * mid,
};
if (point.elevation < groundAt(point.offset)) lo = mid;
else hi = mid;
}
const t = (lo + hi) / 2;
return {
offset: low.offset + (high.offset - low.offset) * t,
elevation: low.elevation + (high.elevation - low.elevation) * t,
};
};
// ㄴ형 = 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 * lean * wallHeight * t;
};
const floorTopInner = wallOuterAt(anchor.elevation);
const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness);
const floorOuter = floorTopInner + outward * (adjust.innerWidthM + memberT);
const floorTaper = lean * 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)로 **절토선**
// · 원지반 밖으로 나오면 → **성토(되메움)선** — 1:1.2로 지반까지.
let basinCut: BasinLayout["cutLine"] = null;
let fillStart: OffsetPoint | null = null;
if (shape !== "I") {
const refTop =
shape === "U"
? {
offset: mirroredWallBase + outward * lean * 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) {
// 원지반 밖으로 나왔다 — 성토부선은 유출측과 같은 체계(1:1.2 + 5m 다단)로
// 본체가 그린다(2026-08-22 사용자 확정). 여기서는 시작점만 넘긴다.
fillStart = refTop;
}
}
return {
basin: {
shape,
parts,
// 라벨 자리 = I형 벽 하단(2026-08-20 사용자) — 그리기 쪽에서 아래로 내려 붙인다.
label: {
offset: wallBase + outward * (memberT / 2),
elevation: anchor.elevation - floorThickness,
},
reason,
cutLine: basinCut,
// I형 관 하단 되메움선만 여기 담긴다(applyBasinPipeFill). ㄴ·ㄷ형 성토는
// 성토부선 체계로 옮겼다.
fillLine: null,
},
// 관 끝선 = 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"
? iGroundIntersection()
: { offset: floorTopInner, elevation: anchor.elevation },
fillStart,
fillBottomElevation: anchor.elevation - floorThickness,
trimOffset: wallBase - outward * lean * 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,
adjust: {
innerWidthM: BASIN_INNER_WIDTH_M,
innerHeightM: BASIN_INNER_HEIGHT_M,
lateralM: 0,
slopeM: 0,
},
});
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;
}
}
/** 집수정 조립 결과 — 본체가 트림·접근선·관 접속에 그대로 꽂아 쓴다. */
export interface BasinAttachResult extends BasinBuildResult {
/** 성토 사면선 폴리라인(노견 → 수평 연장 → 벽 상단). 없으면 트림만. */
approach: { points: OffsetPoint[] } | null;
}
/** buildBasin + 접근선까지 한 번에 — 본체(Geom) 700줄 제한용 묶음. */
export function attachBasin(input: BasinBuildInput): BasinAttachResult {
const built = buildBasin(input);
const points = basinApproachPoints(input.edge, input.outward, input.adjust, {
offset: built.trimOffset,
elevation: built.trimElevation,
});
return { ...built, approach: points ? { points } : null };
}
/**
* I형 집수정 **이동 한계**(2026-08-22 사용자 확정) — I형은 관을 감싸는 구조라 옮기면
* 관 시작점이 따라가 **관 길이(m 규격)가 바뀐다**. 길이가 바뀌는 지점 직전이 최대다.
* 요청값에서 0.1씩(상하 먼저, 다음 좌우) 되돌려 기준 길이와 같아지는 자리를 찾는다.
* `probe`는 같은 단면을 다시 계산하는 함수(본체가 재귀로 물려 준다).
*/
export function clampBasinMove(
adjust: BasinAdjust,
probe: (candidate: BasinAdjust) => { shape: BasinShape | null; lengthM: number } | null,
): BasinAdjust {
if (adjust.lateralM <= 1e-9 && adjust.slopeM <= 1e-9) return adjust;
const base = probe({ ...adjust, lateralM: 0, slopeM: 0 });
if (base?.shape !== "I") return adjust;
const round1 = (value: number): number => Math.round(value * 10) / 10;
let lateralM = adjust.lateralM;
let slopeM = adjust.slopeM;
for (let pass = 0; pass < 400 && (lateralM > 1e-9 || slopeM > 1e-9); pass += 1) {
if (probe({ ...adjust, lateralM, slopeM })?.lengthM === base.lengthM) break;
if (slopeM > 1e-9) slopeM = Math.max(0, round1(slopeM - 0.1));
else lateralM = Math.max(0, round1(lateralM - 0.1));
}
return { ...adjust, lateralM, slopeM };
}