feat(B06): 배수관 기슭막이 여유고·성토 물매 재설계

- 기슭막이 높이 = 관경 + 여유고 0.5m (0.1m 눈금 올림). 좌우 여유고 동일.
- 관 길이는 절단 긴 변 기준 정수 m. 벽 자리 이동으로 맞추고, 물매·5m 제약에
  걸리면 벽 폭으로 흡수(폭은 5m 한계를 넘지 않는 범위까지만).
- 성토 물매를 1:1.2 고정에서 1:1.2~2.0 범위(성토_비탈면.md §1)로 전환.
  벽 자리가 굳은 뒤 물매를 역산해 사면선이 벽 이음선 상단점을 지나게 한다.
  종전에는 관 길이 맞춤이 벽을 옮겨 접점이 깨졌고 스냅으로 가리고 있었다.
- 성토 사면선은 단일 각도 직선 하나를 배수관 레이아웃이 직접 공급(설계선 대체).
- 벽 자리 탐색 상한 = 성토 사면 끝, 사면길이 5m 초과 전까지. 성토고 3m 이상이면
  5m 쪽으로 끌어올린다.
- 기슭막이로 성토를 못 받는 자리는 structureRequired로 표시(옹벽·석축 검토).
- 700줄 제한으로 Const/Types/Solve 3개 파일 분리.

검증: 계획고 -2.0~+3.0 스윕 24케이스 문제 0 (접점 오차 <1e-6, 여유고 0.5,
5m 초과는 전부 경고). tsc 통과, pytest 148 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-21 15:51:54 +09:00
co-authored by Claude Opus 5
parent 0d5fba4400
commit a04da05fe1
6 changed files with 777 additions and 328 deletions
+6 -1
View File
@@ -77,7 +77,12 @@ export function appendCulvertOverlay(
` · t=${PITCHING_THICKNESS_M}m(실무 L3=45)` +
` · 시작 변은 기슭막이 전면과 같은 경사, 윗면 시작점 = 관 하단 꼭짓점` +
` · 성토사면 경사길이 ${layout.fillSlope.lengthM.toFixed(2)}m` +
`(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})`,
`(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})` +
` · 성토 물매 1:${layout.fillSlope.ratio.toFixed(2)}` +
`(허용 1:1.2~2.0${layout.fillSlope.ratioClamped ? " 벗어남" : " 충족"})` +
(layout.fillSlope.structureRequired
? " · ⚠ 기슭막이로 성토를 못 받는 자리 — 옹벽·석축 검토(성토_비탈면 §2)"
: ""),
),
);
};
@@ -0,0 +1,107 @@
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Const.ts
* 배수관 세트 **치수 상수·형태별 한계** — 기하 계산(`_Cross_Culvert_Geom.ts`)에서
* 분리했다(700줄 제한). 값의 근거는 각 상수 주석에 지식DB 출처로 남긴다.
* ========================================================================== */
/** 관 위 최소 토피(m) — 프론트 정의처(백엔드는 `B06_Section_Engine_Culvert.MIN_PIPE_COVER_M`).
* ⚠ 임도 배수관 토피 규정이 지식DB에 없어 별표2 교량·암거 "복토 50㎝ 이상"을 교차
* 참조로 채택(2026-08-19 사용자 확정). B05 배수관 측점 계획고 하향 차단 기준이자,
* 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */
export const MIN_PIPE_COVER_M = 0.5;
/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */
export const REVET_THICKNESS_M = 0.45;
/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */
export const REVET_LEAN_RATIO = 0.3;
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
export const PITCHING_THICKNESS_M = 0.45;
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
/**
* 기슭막이 여유고(m) — 관 상단에서 벽 상단까지의 간격. 유입·유출 **공통**이다.
*
* ⚠ 확정값 아님(2026-08-21 사용자 지정). 지식DB에 임도 배수관 기슭막이 여유고
* 규정이 없다(`통수단면.md` §여유고 — "임도·KDS 원문에 수치 없음"). 관 상단 최소
* 토피 0.5m와 같은 값을 잠정 채택했고, 추후 **사용자 입력값**으로 교체 예정이다.
*
* 이 값 때문에 벽 높이는 좌·우가 같은 규칙으로 정해진다 — 관이 경사져 벽 밑(invert)
* 이 서로 달라도 관 상단 위 간격은 항상 같다.
*/
export const REVET_FREEBOARD_M = 0.5;
/** 기슭막이 높이 눈금(m) — 계산 높이를 이 단위로 올린다(2026-08-21 사용자 확정). */
export const REVET_HEIGHT_STEP_M = 0.1;
/** 관경에 여유고를 더해 눈금(0.1m)으로 올린 기슭막이 높이(m). */
export function revetTargetHeight(diameterM: number): number {
const raw = diameterM + REVET_FREEBOARD_M;
return Math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
}
/**
* 관 벽 두께(m) — 관종별. 지식DB 조회(2026-08-20):
* · 파형강관: 강판 두께 **1.6T~3.2T**(교본 부록 4-6 §2.5.2, 표-1 표준형 강관 치수,
* KS D 3590). Ø1000 이상은 1.6~3.2 전 규격 사용 — 중간값 2.7T 채택.
* · 흄관·VR관(원심력 철근콘크리트관): 관경별 벽 두께 표가 지식DB에 없어
* 관경의 **1/12**을 적용한다(⚠ 근거 미확정 — KS F 4403 확보 시 교체).
* 도면상 내경·외경 2줄을 긋기 위한 값이다.
*/
export function pipeWallThicknessM(kind: string | null, diameterM: number): number {
if (kind && kind.includes("파형강관")) return 0.0027;
return Math.max(diameterM / 12, 0.05);
}
/**
* 기슭막이 근입 깊이(m) — 벽 바닥이 원지반 아래로 묻히는 깊이.
*
* 지식DB 조회 결과(2026-08-20):
* · 기슭막이 자체의 근입 깊이 **규정 없음** — 사방교본은 "기초 세굴 위험 시
* 밑다짐·바닥막이·수제 병행"만 정한다(`00_구조물_선정기준.md` §4).
* · 흙막이: "하부 땅밀림 방지 위해 **본바닥(원지반)에 기초**"(`흙막이.md`) —
* 원지반에 묻어야 한다는 원칙만 있고 수치는 없다.
* · 실무(울진소광 구조도): 돌기슭막이 **기초형 = 기초콘크리트 H=0.5**,
* 깬돌 3=45·1:0.3 (`_원단위라이브러리_울진소광.md` §4).
* → 실무 기초콘크리트 높이 0.5m를 근입 깊이로 채택한다. 임도 법정 확정값이 아니라
* **실무 관측 기반**이므로 확정 기준 확보 시 교체한다.
*/
export const REVET_EMBED_DEPTH_M = 0.5;
/**
* 기슭막이 형태별 높이 한계(m) — 지식DB `돌쌓기.md` §1 / `흙막이.md` §2:
* 돌 **찰쌓기 3.0m 이하**, 돌 **메쌓기 2.0m 이하**(교본 7-3 돌흙막이 기준),
* 돌망태 2m 정도. 큰돌쌓기는 총높이 한계 없음(1일 시공 1.5m 제한만).
* 초과 시 콘크리트 옹벽 등 상위 구조물 검토 대상이다.
*/
export function revetHeightLimit(form: string | null | undefined): number {
if (!form) return 3.0;
if (form.includes("메")) return 2.0;
if (form.includes("찰")) return 3.0;
if (form.includes("돌망태")) return 2.0;
if (form.includes("콘크리트")) return 4.0; // 콘크리트 흙막이 산복기초 4.0m 이하
return 3.0;
}
/**
* 성토 비탈 기울기(1:n) 허용 범위 — 지식DB `성토_비탈면.md` §1:
* "**1:1.2~2.0** 범위에서 토질·용수 등 지형여건을 종합 고려해 설정".
* 1.2는 **범위의 급한 끝**이지 고정값이 아니다 — 배수관 구조물이 서는 측점에서는
* 사면선이 기슭막이 이음선 상단점을 지나도록 이 범위 안에서 물매를 정한다
* (2026-08-21 사용자 지시).
*/
export const FILL_SLOPE_RATIO_MIN = 1.2;
export const FILL_SLOPE_RATIO_MAX = 2.0;
/** 성토사면 길이 한계(m) — `성토_비탈면.md` §2 "5m 이내". 초과 시 옹벽·석축 등 의무. */
export const FILL_SLOPE_MAX_LENGTH_M = 5.0;
/**
* 성토 전체고(노견 − 원지반) 기준(m) — 이 높이를 넘으면 1:1.2로도 사면이 5m를 넘어
* 구조물이 필요해진다(3m × √(1+1.2²) = 4.69m, 3.2m에서 5.0m). 기슭막이를 자연 사면
* 끝이 아니라 **사면길이 5m 지점**으로 끌어올리는 판정에 쓴다(2026-08-21 사용자 지시).
*/
export const FILL_STRUCTURE_HEIGHT_M = 3.0;
+289 -324
View File
@@ -21,252 +21,44 @@
* 그리지 않고 집수정 단면(I/ㄴ/ㄷ형)으로 바꾼다.
* ========================================================================== */
import type { CrossSection, CulvertSideSpec, SectionSample } from "./B06_Section_Api_Fetch";
import {
FILL_SLOPE_MAX_LENGTH_M,
FILL_STRUCTURE_HEIGHT_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
MIN_PIPE_COVER_M,
PITCHING_THICKNESS_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type {
CrossSection,
CulvertSet,
CulvertSideSpec,
SectionSample,
} from "./B06_Section_Api_Fetch";
BasinLayout,
BasinShape,
CulvertLayout,
EndFace,
OffsetPoint,
PipeEnd,
WallLayout,
} from "./B06_Section_UI_Cross_Culvert_Types";
/** 관 위 최소 토피(m) — 프론트 정의처(백엔드는 `B06_Section_Engine_Culvert.MIN_PIPE_COVER_M`).
* ⚠ 임도 배수관 토피 규정이 지식DB에 없어 별표2 교량·암거 "복토 50㎝ 이상"을 교차
* 참조로 채택(2026-08-19 사용자 확정). B05 배수관 측점 계획고 하향 차단 기준이자,
* 절토측 유입 벽에서 관 invert의 상한(노면 − 관경 − 토피)을 정한다. */
export const MIN_PIPE_COVER_M = 0.5;
// 상수·자료형은 분리 파일에 있고, 기존 import 경로를 유지하기 위해 그대로 다시 내보낸다.
export * from "./B06_Section_UI_Cross_Culvert_Const";
export * from "./B06_Section_UI_Cross_Culvert_Types";
/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */
export const REVET_THICKNESS_M = 0.45;
/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */
export const REVET_LEAN_RATIO = 0.3;
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
export const PITCHING_THICKNESS_M = 0.45;
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
/**
* 관 벽 두께(m) — 관종별. 지식DB 조회(2026-08-20):
* · 파형강관: 강판 두께 **1.6T~3.2T**(교본 부록 4-6 §2.5.2, 표-1 표준형 강관 치수,
* KS D 3590). Ø1000 이상은 1.6~3.2 전 규격 사용 — 중간값 2.7T 채택.
* · 흄관·VR관(원심력 철근콘크리트관): 관경별 벽 두께 표가 지식DB에 없어
* 관경의 **1/12**을 적용한다(⚠ 근거 미확정 — KS F 4403 확보 시 교체).
* 도면상 내경·외경 2줄을 긋기 위한 값이다.
*/
export function pipeWallThicknessM(kind: string | null, diameterM: number): number {
if (kind && kind.includes("파형강관")) return 0.0027;
return Math.max(diameterM / 12, 0.05);
}
/**
* 기슭막이 근입 깊이(m) — 벽 바닥이 원지반 아래로 묻히는 깊이.
*
* 지식DB 조회 결과(2026-08-20):
* · 기슭막이 자체의 근입 깊이 **규정 없음** — 사방교본은 "기초 세굴 위험 시
* 밑다짐·바닥막이·수제 병행"만 정한다(`00_구조물_선정기준.md` §4).
* · 흙막이: "하부 땅밀림 방지 위해 **본바닥(원지반)에 기초**"(`흙막이.md`) —
* 원지반에 묻어야 한다는 원칙만 있고 수치는 없다.
* · 실무(울진소광 구조도): 돌기슭막이 **기초형 = 기초콘크리트 H=0.5**,
* 깬돌 3=45·1:0.3 (`_원단위라이브러리_울진소광.md` §4).
* → 실무 기초콘크리트 높이 0.5m를 근입 깊이로 채택한다. 임도 법정 확정값이 아니라
* **실무 관측 기반**이므로 확정 기준 확보 시 교체한다.
*/
export const REVET_EMBED_DEPTH_M = 0.5;
/**
* 기슭막이 형태별 높이 한계(m) — 지식DB `돌쌓기.md` §1 / `흙막이.md` §2:
* 돌 **찰쌓기 3.0m 이하**, 돌 **메쌓기 2.0m 이하**(교본 7-3 돌흙막이 기준),
* 돌망태 2m 정도. 큰돌쌓기는 총높이 한계 없음(1일 시공 1.5m 제한만).
* 초과 시 콘크리트 옹벽 등 상위 구조물 검토 대상이다.
*/
export function revetHeightLimit(form: string | null | undefined): number {
if (!form) return 3.0;
if (form.includes("메")) return 2.0;
if (form.includes("찰")) return 3.0;
if (form.includes("돌망태")) return 2.0;
if (form.includes("콘크리트")) return 4.0; // 콘크리트 흙막이 산복기초 4.0m 이하
return 3.0;
}
export interface OffsetPoint {
offset: number;
elevation: number;
}
/** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */
export interface CulvertDesignTrim {
minOffset: number;
maxOffset: number;
}
export interface WallLayout {
role: "inlet" | "outlet";
form: string | null;
lengthM: number | null;
/** 배면(도로측) 바닥 offset. */
backOffset: number;
/** 전면(계류측) 바닥 offset. */
outerOffset: number;
base: number;
height: number;
outward: number;
/** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */
points: OffsetPoint[];
/** 상단 배면 꼭짓점 — 성토 사면선과의 접점(설계선 트림 경계). */
topBack: OffsetPoint;
}
/**
* 유입 집수정 단면 — 실무 도면(2026-08-20 사용자 제공 L형 확대본) 기준.
* · **I형** = 기슭막이와 같은 **평행사변형 벽 1매**만.
* · **ㄴ(L)형** = I형 + **바닥판**.
* · **ㄷ형** = ㄴ형 + I형 **반대측 막음벽**.
* 돌 표현은 재질 확정 시 반영 예정(성토부 기슭막이와 동일 규칙) — 지금은 윤곽만.
*/
export type BasinShape = "I" | "L" | "U";
export interface BasinLayout {
shape: BasinShape;
/** 부재별 폴리곤(벽·바닥·막음벽). 각각 분리 도형으로 그린다. */
parts: Array<{ kind: "wall" | "floor"; points: OffsetPoint[] }>;
label: OffsetPoint;
/** 자동 전환 사유 — 툴팁·라벨에 쓴다. */
reason: "cut" | "blocked";
/** 집수정이 원지반 안으로 들어갈 때의 절토선(구조물 바깥 끝 → 지반 교차점). */
cutLine: { from: OffsetPoint; to: OffsetPoint } | null;
}
/** 관 끝단을 자를 마감면 — 구조물(기슭막이·집수정)의 계류측 변을 **그대로 복사**한다. */
export interface EndFace {
base: OffsetPoint;
direction: { offset: number; elevation: number };
}
/** 관 한쪽 끝단의 두 꼭짓점(마감면에서 자른 결과). */
export interface PipeEnd {
bottom: OffsetPoint;
top: OffsetPoint;
}
/** 유출측 보호공(돌붙임) 띠 — 윗면/밑면 폴리라인 쌍. */
export interface PitchingLayout {
top: OffsetPoint[];
base: OffsetPoint[];
lengthM: number;
/** 최소 보호공 길이(낙차고×2 — 백엔드 `apron_length_m`). */
minLengthM: number;
}
export interface CulvertLayout {
culvert: CulvertSet;
pipe: { inlet: OffsetPoint; outlet: OffsetPoint; lengthM: number; slopePct: number };
/** 관 축·법선 단위벡터(그리기가 두께 방향으로 밀 때 쓴다). */
pipeAxis: { axis: OffsetPoint; normal: OffsetPoint };
/** 관 양 끝단 꼭짓점 — 보호공 시작점이 유출 하단 꼭짓점을 그대로 쓴다. */
pipeCorners: { inlet: PipeEnd; outlet: PipeEnd };
walls: WallLayout[];
/** 유출측 성토사면 경사길이(m)와 법정 5m 충족 여부(별표2). */
fillSlope: { lengthM: number; withinLimit: boolean };
/** 유출측 보호공 띠(없으면 top/base 빈 배열). */
pitching: PitchingLayout;
/** 유입 집수정 단면(있을 때만). */
basin: BasinLayout | null;
designTrim: CulvertDesignTrim | null;
}
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
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. */
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. */
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). 유출 벽 자리의 바깥 한계. */
const FILL_SLOPE_MAX_LENGTH_M = 5.0;
/** 관 축과 벽 전면이 거의 나란하면 교점이 멀리 발산한다 — 이 거리를 넘으면 축 직각 마감. */
const STRAY_LIMIT_M = 2.5;
/** 노견에서 바깥으로 훑어 설계선(사면)이 지반과 만나는 첫 지점(사면 끝)을 찾는다.
* 사면 경사길이 5m 한계(별표2)에 걸리면 그 지점에서 멈춘다 — 거기가 구조물 자리다. */
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;
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;
}
import {
fillSlopeOf,
groundInterpolator,
designInterpolator,
intersect,
slopeToeOffset,
solveFillWallOffset,
STRAY_LIMIT_M,
} from "./B06_Section_UI_Cross_Culvert_Solve";
import type { FillSlopeSegment, FillWallPlacement } from "./B06_Section_UI_Cross_Culvert_Solve";
/** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */
export function computeCulvertLayout(
@@ -303,43 +95,6 @@ export function computeCulvertLayout(
const inletInvert = Math.min(groundAt(inletInfo.edge.offset_m), invertCap(inletInfo.edge));
const inlet: OffsetPoint = { offset: inletInfo.edge.offset_m, elevation: inletInvert };
// 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2).
// 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면).
const outletAnchorOffset = designAt
? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit)
: outletInfo.edge.offset_m;
// 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①).
let fillSlopeLength = 0;
if (designAt) {
const from = outletInfo.edge.offset_m;
const steps = 60;
let prevElev = designAt(from);
for (let i = 1; i <= steps; i += 1) {
const o = from + ((outletAnchorOffset - from) * i) / steps;
const e = designAt(o);
fillSlopeLength += Math.hypot((outletAnchorOffset - from) / steps, e - prevElev);
prevElev = e;
}
}
// 역경사는 수평으로 클램프(수평 가능 — 사용자 확정).
const outletInvert = Math.min(groundAt(outletAnchorOffset), inletInvert);
const outletAnchor: OffsetPoint = { offset: outletAnchorOffset, elevation: outletInvert };
const run0 = outletAnchor.offset - inlet.offset;
const rise0 = outletAnchor.elevation - inlet.elevation;
const length0 = Math.hypot(run0, rise0);
if (!(length0 > 0.5)) return null;
// 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(기슭막이를 조금 벗어나도 무관,
// 2026-08-20 사용자 확정). 실무 표기 L=10~12m대가 여기서 나온다.
let lengthM = Math.ceil(length0 - 1e-6);
const scale = lengthM / length0;
const outlet: OffsetPoint = {
offset: inlet.offset + run0 * scale,
elevation: inlet.elevation + rise0 * scale,
};
const span = Math.abs(outletAnchor.offset - inlet.offset);
const slopePct = span > 0 ? ((inletInvert - outletInvert) / span) * 100 : 0;
// 유입측 절토 판정: 절토측 유입은 자연스럽게 집수정(ㄴ형 기본)이 된다.
const mode = section.design?.section_mode;
const inletSideName: "left" | "right" = uphill === "left" ? "left" : "right";
@@ -359,6 +114,66 @@ export function computeCulvertLayout(
? "blocked"
: null;
// 성토측 유입 기슭막이는 노견에 붙여 두지 않는다 — 사면선이 벽 이음선 상단점을 지나야
// 하므로 자리를 풀어서 정한다(2026-08-21 사용자 ②). 집수정(절토·막힘)은 측구부 자리
// 그대로 둔다. 벽 높이는 관경 + 여유고(0.1m 눈금 올림).
const wallHeightFor = (spec: CulvertSideSpec): number =>
Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
let inletPlacement: FillWallPlacement | null = null;
if (!basinReason && designAt) {
// 훑는 범위는 **성토 사면 끝까지**다 — 그 너머는 받칠 성토가 없다. 성토가 얕아 벽
// 상단이 노견보다 높은 구간에서는 물매가 성립하지 않는데, 한계를 지반 샘플 끝까지
// 두면 벽이 몇 m 밖으로 밀려난다(계획고 스윕에서 확인).
inletPlacement = solveFillWallOffset(
inletInfo.edge,
inletInfo.outward,
wallHeightFor(culvert.inlet),
(offset) => Math.min(groundAt(offset), invertCap(inletInfo.edge)),
slopeToeOffset(designAt, groundAt, inletInfo.edge.offset_m, inletInfo.limit),
);
if (inletPlacement) {
inlet.offset = inletPlacement.offset;
inlet.elevation = Math.min(groundAt(inletPlacement.offset), invertCap(inletInfo.edge));
}
}
// 유출 목표점 = 유출측 성토사면이 지반과 만나는 사면 끝(경사길이 5m 한계 — 별표2).
// 그 자리가 유출 기슭막이 자리이고, invert는 원지반(관 끝이 원지반 위 — 실무 도면).
const outletAnchorOffset = designAt
? slopeToeOffset(designAt, groundAt, outletInfo.edge.offset_m, outletInfo.limit)
: outletInfo.edge.offset_m;
// 성토사면 경사길이 실측(노견 → 사면 끝) — 법정 5m 이내 충족 확인용(사용자 ①).
let fillSlopeLength = 0;
if (designAt) {
const from = outletInfo.edge.offset_m;
const steps = 60;
let prevElev = designAt(from);
for (let i = 1; i <= steps; i += 1) {
const o = from + ((outletAnchorOffset - from) * i) / steps;
const e = designAt(o);
fillSlopeLength += Math.hypot((outletAnchorOffset - from) / steps, e - prevElev);
prevElev = e;
}
}
// 역경사는 수평으로 클램프(수평 가능 — 사용자 확정).
const outletInvert = Math.min(groundAt(outletAnchorOffset), inlet.elevation);
const outletAnchor: OffsetPoint = { offset: outletAnchorOffset, elevation: outletInvert };
const run0 = outletAnchor.offset - inlet.offset;
const rise0 = outletAnchor.elevation - inlet.elevation;
const length0 = Math.hypot(run0, rise0);
if (!(length0 > 0.5)) return null;
// 관은 m 단위 설치 — 올림 연장분은 유출 쪽으로(기슭막이를 조금 벗어나도 무관,
// 2026-08-20 사용자 확정). 실무 표기 L=10~12m대가 여기서 나온다.
let lengthM = Math.ceil(length0 - 1e-6);
const scale = lengthM / length0;
const outlet: OffsetPoint = {
offset: inlet.offset + run0 * scale,
elevation: inlet.elevation + rise0 * scale,
};
const span = Math.abs(outletAnchor.offset - inlet.offset);
let slopePct = span > 0 ? ((inlet.elevation - outletInvert) / span) * 100 : 0;
// 기슭막이 합성 단면(사용자 ①·②): 하부 = 배면 수직·전면 1:0.3 **사다리꼴**(관이
// 지나는 관경 높이), 상부 = 전·배면이 나란히 기운 **평행사변형**. 상단 배면 꼭짓점이
// 성토 사면선(설계선)과 만나는 접점이 되도록 높이를 정한다 — 사면선은 거기서 끊긴다.
@@ -373,11 +188,20 @@ export function computeCulvertLayout(
};
let trimMin = Number.NEGATIVE_INFINITY;
let trimMax = Number.POSITIVE_INFINITY;
// 트림 경계에서 성토 경사선 끝단이 **닿아야 할 표고** — 벽 높이를 관경+여유고로 고정한
// 뒤로는 설계 사면선이 벽 상단과 어긋나므로, 사면선 끝을 이 점까지 내려 맞춘다
// (2026-08-21 사용자 ② — 평행사변형·사다리꼴 교차 두 점 중 **상단** 교차점).
let trimMinElevation: number | null = null;
let trimMaxElevation: number | null = null;
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도). 설계선 대신 그린다. */
let trimMinSlope: { from: OffsetPoint; to: OffsetPoint } | null = null;
let trimMaxSlope: { from: OffsetPoint; to: OffsetPoint } | null = null;
const buildWall = (
spec: CulvertSideSpec,
anchor: OffsetPoint,
outward: number,
forceBasinReason: BasinLayout["reason"] | null,
thickness: number = REVET_THICKNESS_M,
): WallLayout | null => {
const reason: BasinLayout["reason"] | null =
forceBasinReason ?? (spec.structure === "집수정" ? "cut" : null);
@@ -487,8 +311,13 @@ export function computeCulvertLayout(
// 성토 사면선은 **벽 상단 도로측 꼭지점(교점)**에서 끊는다 — 바닥 끝을 기준으로
// 두면 사면선이 집수정을 뚫고 들어간다(2026-08-20 사용자 ①).
const wallTopInner = wallBase - outward * REVET_LEAN_RATIO * wallHeight;
if (outward > 0) trimMax = Math.min(trimMax, wallTopInner);
else trimMin = Math.max(trimMin, wallTopInner);
if (outward > 0) {
trimMax = wallTopInner;
trimMaxElevation = anchor.elevation + wallHeight;
} else {
trimMin = wallTopInner;
trimMinElevation = anchor.elevation + wallHeight;
}
// 관 유입 끝단 마감면 = 집수정 벽의 **계류측 변**을 그대로 복사(사용자 ③).
const faceBottom = {
offset: wallBase + outward * REVET_THICKNESS_M,
@@ -517,18 +346,12 @@ export function computeCulvertLayout(
// **전면(계류측)은 1:0.3 기운 평행사변형 띠**. 띠 안쪽 평행선과 수직 배면 사이가
// 사다리꼴 — 합집합의 내부 대각 이음선은 배면 상단에서 바닥으로 내려온다.
// 바닥이 넓고 상단이 좁다(상단 폭 = 띠 두께 0.45).
// 높이: **이음선 상단점(평행사변형·사다리꼴 교차점)**이 성토 경사선과 교차하는
// 지점까지(2026-08-20 사용자 스케치 — 빨간 점). 하한은 관경 + 돌 한 단(0.45) —
// 관이 벽을 관통하므로 벽은 최소한 관을 덮어야 한다(사면 끝의 유출 벽은 접점
// 높이가 0이라 하한이 실제 높이가 된다). 옵션 높이는 상한.
const minHeight = diameter + REVET_THICKNESS_M;
const topJoint = anchor.offset + outward * REVET_TRAP_TOP_M;
let height = designAt
? Math.max(designAt(topJoint) - anchor.elevation, minHeight)
: (spec.revet_height_m ?? minHeight);
if (spec.revet_height_m != null) height = Math.min(height, spec.revet_height_m);
// 높이: **관경 + 여유고**를 0.1m 눈금으로 올린 값(2026-08-21 사용자 확정). 관이
// 물매를 두어 좌·우 invert가 달라도 **관 상단 위 여유고는 같아야** 한다. 사면선은
// 높이를 정하지 않고 벽이 설 **자리**만 정한다(`solveFillWallOffset`).
const topJoint = anchor.offset + outward * (thickness / 2);
// 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다.
height = Math.min(height, revetHeightLimit(spec.revet_form));
const height = Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const topBack: OffsetPoint = {
offset: anchor.offset,
@@ -537,9 +360,9 @@ export function computeCulvertLayout(
// 상단 변: 사다리꼴 상단(0.45) + 띠 상단(0.45). 이음선은 상단 변 중간점(topJoint)
// 에서 1:0.3으로 바닥까지 — 사다리꼴(수직 배면, 상단 0.45 → 하단 0.45+0.3H)과
// 평행사변형 띠(폭 0.45)가 진짜 사다리꼴·평행사변형이 된다.
const topFront = topJoint + outward * REVET_THICKNESS_M;
const topFront = topJoint + outward * thickness;
const jointBase = topJoint + outward * REVET_LEAN_RATIO * height;
const frontBase = jointBase + outward * REVET_THICKNESS_M;
const frontBase = jointBase + outward * thickness;
// 근입: 벽 바닥은 원지반 아래 REVET_EMBED_DEPTH_M만큼 묻힌다(흙막이 "본바닥에
// 기초" 원칙 + 실무 기초콘크리트 H=0.5). 바닥 꼭짓점 2개만 내리고 상단·이음선·
// 관 위치는 그대로 — 관은 여전히 원지반 높이로 지난다.
@@ -554,6 +377,7 @@ export function computeCulvertLayout(
height,
outward,
topBack,
topJoint: { offset: topJoint, elevation: anchor.elevation + height },
points: [
{ offset: anchor.offset, elevation: embedBase },
topBack,
@@ -562,9 +386,14 @@ export function computeCulvertLayout(
],
};
walls.push(wall);
// 성토 사면선은 **이음선 상단점**에서 끊는다(교차점 = 트림 경계 — 사용자 스케치).
if (outward > 0) trimMax = Math.min(trimMax, topJoint);
else trimMin = Math.max(trimMin, topJoint);
// 성토 사면선은 **이음선 상단점**에서 끊고, 끝단 표고도 그 점에 맞춘다(사용자 ).
if (outward > 0) {
trimMax = topJoint;
trimMaxElevation = anchor.elevation + height;
} else {
trimMin = topJoint;
trimMinElevation = anchor.elevation + height;
}
// 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③).
endFaces[spec.role] = {
base: { offset: frontBase, elevation: embedBase },
@@ -576,32 +405,33 @@ export function computeCulvertLayout(
return wall;
};
buildWall(culvert.inlet, inlet, inletInfo.outward, basinReason);
// 유출 벽 자리: 사면 끝에서는 사면선이 벽 상단보다 낮아 교차점이 성립하지 않는다.
// 관 축을 따라 노견 쪽으로 되짚으며 **이음선 상단점(base + 하한높이)이 사면선과
// 만나는 지점**을 찾는다 — 관·보호공 끝(anchor)은 그대로 두고 벽만 그 자리에 선다.
// 유출 벽 자리 — 유입과 같은 규칙(사면선이 벽 이음선 상단점을 지나는 자리, 물매는
// 1:1.2~2.0 범위에서 역산). 벽 밑은 관 축을 따라간 invert다 — 관이 벽을 관통하므로
// 벽 바닥은 관 자리를 따라야 한다.
const axisRun0 = outletAnchor.offset - inlet.offset;
const axisRise0 = outletAnchor.elevation - inlet.elevation;
const invertAt = (offset: number): number =>
Math.abs(axisRun0) > 1e-9
? inlet.elevation + axisRise0 * ((offset - inlet.offset) / axisRun0)
: inlet.elevation;
let outletWallAnchor = outletAnchor;
let outletPlacement: FillWallPlacement | null = null;
if (designAt) {
const axisRun = outletAnchor.offset - inlet.offset;
const axisRise = outletAnchor.elevation - inlet.elevation;
const invertAt = (offset: number): number =>
Math.abs(axisRun) > 1e-9
? inlet.elevation + axisRise * ((offset - inlet.offset) / axisRun)
: inlet.elevation;
const minWallHeight = diameter + REVET_THICKNESS_M;
const start = outletInfo.edge.offset_m;
const steps = 80;
for (let i = 0; i <= steps; i += 1) {
const offset = start + ((outletAnchor.offset - start) * i) / steps;
const invert = invertAt(offset);
const jointOffset = offset + outletInfo.outward * REVET_TRAP_TOP_M;
// 사면선고 − invert가 하한 높이까지 내려온 첫 지점 = 교차 성립 자리.
if (designAt(jointOffset) - invert <= minWallHeight + 1e-6) {
outletWallAnchor = { offset, elevation: invert };
break;
}
outletPlacement = solveFillWallOffset(
outletInfo.edge,
outletInfo.outward,
wallHeightFor(culvert.outlet),
invertAt,
outletAnchorOffset,
);
if (outletPlacement) {
outletWallAnchor = {
offset: outletPlacement.offset,
elevation: invertAt(outletPlacement.offset),
};
}
}
const outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null);
let outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null);
// 유출 벽이 사면 끝보다 안쪽으로 당겨졌으면 **관 끝도 그 자리에 맞춘다** — 관만 사면
// 끝까지 남으면 벽과 떨어져 마감면 교차가 실패한다(2026-08-20 사용자 ② 원인).
if (outletWall && Math.abs(outletWallAnchor.offset - outletAnchor.offset) > 0.05) {
@@ -649,11 +479,101 @@ export function computeCulvertLayout(
STRAY_LIMIT_M;
return strayed ? fallback : { bottom, top };
};
const pipeCorners = {
let pipeCorners = {
inlet: endCorners("inlet", pipeStart),
outlet: endCorners("outlet", outlet),
};
// ── 관 길이 m 단위 맞춤(2026-08-21 사용자 ①). 관 끝은 기운 벽 전면으로 잘려 **상단·하단
// 길이가 다르다** — 긴 변을 기준으로 올림해 정수 m로 잡고, 모자란 만큼 **유출 벽 자리**
// 를 관 축 방향 바깥으로 민다(벽 두께는 0.45 고정 — 사용자 확정). 벽 상단이 사면선과
// 벌어지는 문제는 설계선 트림이 벽 상단을 따라오므로 생기지 않는다(사용자 ②).
const cutLength = (corners: { inlet: PipeEnd; outlet: PipeEnd }): number =>
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)와 사면길이(노견 → 이음선 상단점). */
const outletSlopeAt = (offset: number, thickness: number): { ratio: number; lengthM: number } => {
const joint = offset + outletInfo.outward * (thickness / 2);
const rise = outletInfo.edge.elevation_m - (invertAt(offset) + wallHeightFor(culvert.outlet));
const run = Math.abs(joint - outletInfo.edge.offset_m);
return {
ratio: rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY,
lengthM: Math.hypot(run, Math.max(rise, 0)),
};
};
/** 유출 벽 두께 — 자리 이동만으로 관 길이를 정수로 못 맞출 때만 0.45에서 벗어난다. */
let outletThickness = REVET_THICKNESS_M;
if (outletWall) {
const target = Math.ceil(cutLength(pipeCorners) - 1e-6);
const rebuild = (): void => {
walls.pop();
outletWall = buildWall(
culvert.outlet,
outletWallAnchor,
outletInfo.outward,
null,
outletThickness,
);
outlet.offset = pipeStart.offset + axis.offset * target;
outlet.elevation = pipeStart.elevation + axis.elevation * target;
pipeCorners = {
inlet: endCorners("inlet", pipeStart),
outlet: endCorners("outlet", outlet),
};
};
for (let pass = 0; pass < 8; pass += 1) {
const gap = target - cutLength(pipeCorners);
if (Math.abs(gap) < 0.005) break;
// ① 벽 자리를 관 축 방향으로 gap 만큼 옮긴다(두께 불변 — 사용자 확정 우선순위).
const moved = outletWallAnchor.offset + axis.offset * gap;
const movedSlope = outletSlopeAt(moved, outletThickness);
if (
movedSlope.ratio >= FILL_SLOPE_RATIO_MIN &&
movedSlope.ratio <= FILL_SLOPE_RATIO_MAX &&
movedSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
) {
outletWallAnchor = { offset: moved, elevation: invertAt(moved) };
rebuild();
if (!outletWall) break;
continue;
}
// ② 자리를 더 옮기면 성토 물매가 1:1.2~2.0을 벗어나거나 사면이 5m를 넘는다 —
// 자리는 그대로 두고 **벽 폭**으로 나머지를 흡수한다(2026-08-21 사용자 폴백).
// 전면(관을 자르는 변)은 두께를 t 늘리면 t의 1.5배만큼 바깥으로 간다
// (이음선 t/2 + 전면 t). 관 축에 투영한 몫만 길이에 반영된다.
const grip = Math.max(Math.abs(axis.offset), 0.2) * 1.5;
const wanted = outletThickness + gap / grip;
let capped = Math.min(Math.max(wanted, REVET_THICKNESS_M * 0.6), REVET_THICKNESS_M * 2.5);
// 폭을 넓히면 이음선도 바깥으로 밀려 사면이 길어진다 — **5m 한계가 우선**이라
// 넘는 몫은 포기한다(별표2). 그 경우 관은 표기 길이보다 짧게 벽을 조금 벗어난다
// (2026-08-20 확정 규칙 그대로).
while (
capped > outletThickness &&
outletSlopeAt(outletWallAnchor.offset, capped).lengthM > FILL_SLOPE_MAX_LENGTH_M
) {
capped -= 0.01;
}
if (capped <= outletThickness + 1e-4) break;
outletThickness = capped;
rebuild();
if (!outletWall) break;
}
lengthM = target;
slopePct =
Math.abs(outlet.offset - pipeStart.offset) > 1e-9
? ((pipeStart.elevation - outlet.elevation) / Math.abs(outlet.offset - pipeStart.offset)) *
100
: 0;
}
// 보호공(돌붙임) — 유출측. 2026-08-20 사용자 ①·②:
// ① 시작 변은 **기슭막이 전면과 같은 경사**(밑면을 벽 전면 방향으로 민다),
// 윗면 시작점 = **관 하단 꼭짓점**(관 밑면과 한 점에서 만난다).
@@ -707,6 +627,28 @@ export function computeCulvertLayout(
}
}
// ── 성토 사면 구간 확정. 벽 자리가 다 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)
// 이 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
// 사면은 **단일 각도**(2026-08-21 사용자)라 노견 → 벽 이음선 상단점 직선 하나다.
const slopeOf = (wall: WallLayout): FillSlopeSegment =>
fillSlopeOf(wall, wall.role === "inlet" ? inletInfo.edge : outletInfo.edge);
for (const wall of walls) {
const slope = slopeOf(wall);
// 사면선은 노견부터 우리가 그린다 — 트림 경계를 노견까지 당겨 백엔드 설계선의
// 성토 구간을 통째로 덮는다(설계선은 1:1.2 고정이라 그대로 두면 다시 어긋난다).
if (wall.outward > 0) {
trimMax = slope.from.offset;
trimMaxElevation = null;
trimMaxSlope = { from: slope.from, to: slope.to };
} else {
trimMin = slope.from.offset;
trimMinElevation = null;
trimMinSlope = { from: slope.from, to: slope.to };
}
}
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
return {
culvert,
pipe: { inlet: pipeStart, outlet, lengthM, slopePct },
@@ -714,8 +656,21 @@ export function computeCulvertLayout(
pipeCorners,
walls,
fillSlope: {
lengthM: fillSlopeLength,
withinLimit: fillSlopeLength <= FILL_SLOPE_MAX_LENGTH_M + 1e-6,
lengthM: outletSlope ? outletSlope.lengthM : fillSlopeLength,
withinLimit: outletSlope
? outletSlope.lengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6
: fillSlopeLength <= FILL_SLOPE_MAX_LENGTH_M + 1e-6,
ratio: outletSlope
? outletSlope.ratio
: (section.design?.fill_slope_ratio ?? FILL_SLOPE_RATIO_MIN),
ratioClamped: outletSlope ? outletSlope.clamped : false,
// 벽 자리 풀이가 아예 실패했거나(성토고가 사면 5m 안에 안 들어옴), 성토고 3m 이상
// 인데 물매가 1:1.2보다 급하면 기슭막이로는 못 받는다 — 옹벽·석축 검토 대상.
structureRequired:
outletPlacement === null ||
(outletSlope != null &&
outletSlope.fillHeightM >= FILL_STRUCTURE_HEIGHT_M &&
outletSlope.ratio < FILL_SLOPE_RATIO_MIN),
},
pitching: {
top: pitchingTop,
@@ -724,6 +679,16 @@ export function computeCulvertLayout(
minLengthM: minPitchingM,
},
basin,
designTrim: walls.length || basin ? { minOffset: trimMin, maxOffset: trimMax } : null,
designTrim:
walls.length || basin
? {
minOffset: trimMin,
maxOffset: trimMax,
...(trimMinElevation != null ? { minElevation: trimMinElevation } : {}),
...(trimMaxElevation != null ? { maxElevation: trimMaxElevation } : {}),
...(trimMinSlope ? { minSlope: trimMinSlope } : {}),
...(trimMaxSlope ? { maxSlope: trimMaxSlope } : {}),
}
: null,
};
}
@@ -0,0 +1,221 @@
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Solve.ts
* 배수관 세트 기하의 **보조 풀이** — 보간기·직선 교점·사면 끝 탐색·성토측 구조물
* 자리 풀이. 본체(`_Cross_Culvert_Geom.ts`)에서 분리했다(700줄 제한).
* ========================================================================== */
import type { SectionSample } from "./B06_Section_Api_Fetch";
import {
FILL_SLOPE_MAX_LENGTH_M,
FILL_SLOPE_RATIO_MAX,
FILL_SLOPE_RATIO_MIN,
FILL_STRUCTURE_HEIGHT_M,
REVET_TRAP_TOP_M,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
/** 유효 지반 샘플 → 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 한계(별표2)에 걸리면 그 지점에서 멈춘다 — 거기가 구조물 자리다. */
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;
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;
}
/** 성토측 구조물 자리 풀이 결과 — 벽 offset과 그 자리에서 성립하는 사면 물매. */
export interface FillWallPlacement {
offset: number;
/** 사면선이 벽 이음선 상단점을 지나는 물매(1:n). 범위 밖이면 끝값으로 고정된 값. */
ratio: number;
slopeLengthM: number;
withinLimit: boolean;
ratioClamped: boolean;
/** 그 자리의 성토 전체고(노견 − 벽 상단). 3m 이상이면 벽을 5m 쪽으로 끌어올린다. */
fillHeightM: number;
}
/**
* 성토측 기슭막이 자리 풀이 — 노견에서 바깥으로 훑으며 **성토 사면선이 벽 이음선
* 상단점(평행사변형↔사다리꼴 상단 교차점)을 지나는** 자리를 찾는다.
*
* 종전에는 사면선(백엔드 1:1.2 고정)과의 교차로 벽 **높이**를 정했으나, 높이가
* 관경+여유고로 고정된 뒤로는 그 방식이 성립하지 않는다. 물매는 고정값이 아니라
* **1:1.2~2.0 범위**(`성토_비탈면.md` §1)이므로, 벽 자리를 정하면 물매가 따라 정해진다.
* 노견에서 멀어질수록 물매는 급 → 완만으로 단조 증가하므로 **범위에 처음 드는 자리**
* (= 사면이 가장 짧은 1:1.2 쪽)를 고른다. 사면길이 5m(별표2)를 넘기 전까지만 훑는다.
*
* 범위 안에 드는 자리가 없으면(급경사 지형) 5m 한계 직전 자리를 쓰고 물매를 끝값으로
* 고정한다 — `ratioClamped`로 알린다.
*/
export function solveFillWallOffset(
edge: { offset_m: number; elevation_m: number },
outward: number,
height: number,
baseAt: (offset: number) => number,
limitOffset: number,
): FillWallPlacement | null {
const span = (limitOffset - edge.offset_m) * outward;
if (!(span > 0)) return null;
const steps = 400;
const inRange: FillWallPlacement[] = [];
let last: FillWallPlacement | null = null;
for (let i = 0; i <= steps; i += 1) {
const offset = edge.offset_m + outward * ((span * i) / steps);
const joint = offset + outward * REVET_TRAP_TOP_M;
const rise = edge.elevation_m - (baseAt(offset) + height);
if (!(rise > 1e-6)) continue;
const run = Math.abs(joint - edge.offset_m);
const slopeLengthM = Math.hypot(run, rise);
// 사면길이 5m를 넘는 자리는 구조물 의무 구간이라 벽 자리 후보가 아니다(별표2).
if (slopeLengthM > FILL_SLOPE_MAX_LENGTH_M) break;
const candidate: FillWallPlacement = {
offset,
ratio: run / rise,
slopeLengthM,
withinLimit: true,
ratioClamped: false,
fillHeightM: rise,
};
last = candidate;
if (candidate.ratio >= FILL_SLOPE_RATIO_MIN && candidate.ratio <= FILL_SLOPE_RATIO_MAX) {
inRange.push(candidate);
}
}
if (!last) return null;
if (inRange.length) {
// 성토 전체고가 3m 이상이면 1:1.2로도 사면이 5m에 육박한다 — 벽을 **사면길이 5m
// 쪽(가장 바깥 후보)**으로 끌어올린다(2026-08-21 사용자 ①). 낮은 성토는 사면을
// 짧게 두는 **가장 안쪽 후보**를 쓴다.
return last.fillHeightM >= FILL_STRUCTURE_HEIGHT_M ? inRange[inRange.length - 1] : inRange[0];
}
// 범위 안에 드는 자리가 없다 = 지형이 1:1.2보다 급하거나 1:2.0보다 완만하다.
// 5m 한계 직전 자리를 쓰고 물매를 가까운 끝값으로 고정한다 — 도면에 경고로 알린다.
return {
...last,
ratio: last.ratio < FILL_SLOPE_RATIO_MIN ? FILL_SLOPE_RATIO_MIN : FILL_SLOPE_RATIO_MAX,
withinLimit: last.slopeLengthM <= FILL_SLOPE_MAX_LENGTH_M + 1e-6,
ratioClamped: true,
};
}
/** 노견 → 벽 이음선 상단점 성토 사면 구간(단일 각도)과 그 물매·경고. */
export interface FillSlopeSegment {
ratio: number;
lengthM: number;
clamped: boolean;
fillHeightM: number;
from: OffsetPoint;
to: OffsetPoint;
}
/**
* 벽이 선 자리에서 성립하는 성토 사면 구간을 만든다. 벽 자리가 다 굳은 뒤에 불러야
* 한다 — 관 길이 맞춤(벽 이동)이 끝나기 전에 물매를 잡으면 접점이 다시 어긋난다
* (2026-08-21 사용자 ② 어긋남의 직접 원인이 이 순서였다).
*/
export function fillSlopeOf(
wall: { role: "inlet" | "outlet"; topJoint: OffsetPoint },
edge: { offset_m: number; elevation_m: number },
): FillSlopeSegment {
const from: OffsetPoint = { offset: edge.offset_m, elevation: edge.elevation_m };
const to = wall.topJoint;
const run = Math.abs(to.offset - from.offset);
const rise = from.elevation - to.elevation;
// 그림과 어긋나지 않도록 **실제 물매**를 그대로 보고하고, 1:1.2~2.0을 벗어났는지만
// 따로 알린다(끝값으로 바꿔 보고하면 도면과 수치가 갈린다).
const ratio = rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY;
// 성토고가 0.5m도 안 되면 사면이랄 게 없다 — 물매가 발산해도 경고하지 않는다.
const meaningful = rise >= 0.5;
return {
ratio,
lengthM: Math.hypot(run, rise),
clamped: meaningful && (ratio < FILL_SLOPE_RATIO_MIN || ratio > FILL_SLOPE_RATIO_MAX),
fillHeightM: rise,
from,
to,
};
}
@@ -0,0 +1,114 @@
/* =============================================================================
* B06_Section_UI_Cross_Culvert_Types.ts
* 배수관 세트 기하 **자료형** — 기하 계산(`_Cross_Culvert_Geom.ts`)에서 분리했다
* (700줄 제한). 계산·그리기 양쪽이 함께 쓴다.
* ========================================================================== */
import type { CulvertSet } from "./B06_Section_Api_Fetch";
export interface OffsetPoint {
offset: number;
elevation: number;
}
/** 설계선(성토 경사) 트림 범위 — 이 밖은 벽·보호공이 대신하므로 그리지 않는다. */
export interface CulvertDesignTrim {
minOffset: number;
maxOffset: number;
/** 트림 경계에서 성토 경사선이 **닿아야 할 표고**(= 벽 이음선 상단점). 없으면 설계선 그대로. */
minElevation?: number;
maxElevation?: number;
/** 노견 → 벽 이음선 상단점 **성토 사면 구간**. 백엔드 설계선(1:1.2 고정)을 대신해 그린다. */
minSlope?: { from: OffsetPoint; to: OffsetPoint };
maxSlope?: { from: OffsetPoint; to: OffsetPoint };
}
export interface WallLayout {
role: "inlet" | "outlet";
form: string | null;
lengthM: number | null;
/** 배면(도로측) 바닥 offset. */
backOffset: number;
/** 전면(계류측) 바닥 offset. */
outerOffset: number;
base: number;
height: number;
outward: number;
/** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */
points: OffsetPoint[];
/** 상단 배면 꼭짓점 — 성토 사면선과의 접점(설계선 트림 경계). */
topBack: OffsetPoint;
/** 이음선 상단점(평행사변형↔사다리꼴 상단 교차점) — 성토 사면선이 여기서 끝난다. */
topJoint: OffsetPoint;
}
/**
* 유입 집수정 단면 — 실무 도면(2026-08-20 사용자 제공 L형 확대본) 기준.
* · **I형** = 기슭막이와 같은 **평행사변형 벽 1매**만.
* · **ㄴ(L)형** = I형 + **바닥판**.
* · **ㄷ형** = ㄴ형 + I형 **반대측 막음벽**.
* 돌 표현은 재질 확정 시 반영 예정(성토부 기슭막이와 동일 규칙) — 지금은 윤곽만.
*/
export type BasinShape = "I" | "L" | "U";
export interface BasinLayout {
shape: BasinShape;
/** 부재별 폴리곤(벽·바닥·막음벽). 각각 분리 도형으로 그린다. */
parts: Array<{ kind: "wall" | "floor"; points: OffsetPoint[] }>;
label: OffsetPoint;
/** 자동 전환 사유 — 툴팁·라벨에 쓴다. */
reason: "cut" | "blocked";
/** 집수정이 원지반 안으로 들어갈 때의 절토선(구조물 바깥 끝 → 지반 교차점). */
cutLine: { from: OffsetPoint; to: OffsetPoint } | null;
}
/** 관 끝단을 자를 마감면 — 구조물(기슭막이·집수정)의 계류측 변을 **그대로 복사**한다. */
export interface EndFace {
base: OffsetPoint;
direction: { offset: number; elevation: number };
}
/** 관 한쪽 끝단의 두 꼭짓점(마감면에서 자른 결과). */
export interface PipeEnd {
bottom: OffsetPoint;
top: OffsetPoint;
}
/** 유출측 보호공(돌붙임) 띠 — 윗면/밑면 폴리라인 쌍. */
export interface PitchingLayout {
top: OffsetPoint[];
base: OffsetPoint[];
lengthM: number;
/** 최소 보호공 길이(낙차고×2 — 백엔드 `apron_length_m`). */
minLengthM: number;
}
export interface CulvertLayout {
culvert: CulvertSet;
pipe: { inlet: OffsetPoint; outlet: OffsetPoint; lengthM: number; slopePct: number };
/** 관 축·법선 단위벡터(그리기가 두께 방향으로 밀 때 쓴다). */
pipeAxis: { axis: OffsetPoint; normal: OffsetPoint };
/** 관 양 끝단 꼭짓점 — 보호공 시작점이 유출 하단 꼭짓점을 그대로 쓴다. */
pipeCorners: { inlet: PipeEnd; outlet: PipeEnd };
walls: WallLayout[];
/** 유출측 성토사면 — 경사길이(m)·법정 5m 충족 여부(별표2)·풀어낸 물매(1:n). */
fillSlope: {
lengthM: number;
withinLimit: boolean;
/** 사면선이 벽 이음선 상단점을 지나도록 역산한 물매. 범위 밖이면 끝값으로 고정. */
ratio: number;
/** 물매가 1.2~2.0 범위를 벗어났는지 — 도면 경고용. */
ratioClamped: boolean;
/**
* 기슭막이만으로는 성토를 받을 수 없는 자리(성토고 3m↑인데 사면 5m·물매 1:1.2를
* 동시에 만족하는 벽 자리가 없다) — **옹벽·석축 등 구조물 검토 대상**
* (`성토_비탈면.md` §2 "5m 초과 시 구조물 설치 의무").
*/
structureRequired: boolean;
};
/** 유출측 보호공 띠(없으면 top/base 빈 배열). */
pitching: PitchingLayout;
/** 유입 집수정 단면(있을 때만). */
basin: BasinLayout | null;
designTrim: CulvertDesignTrim | null;
}
+40 -3
View File
@@ -446,7 +446,20 @@ export function appendCrossDesignOverlay(
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
groundSamples: SectionSample[],
trim?: { minOffset: number; maxOffset: number },
trim?: {
minOffset: number;
maxOffset: number;
minElevation?: number;
maxElevation?: number;
minSlope?: {
from: { offset: number; elevation: number };
to: { offset: number; elevation: number };
};
maxSlope?: {
from: { offset: number; elevation: number };
to: { offset: number; elevation: number };
};
},
): void {
const line = design.design_line;
if (!line || line.length < 2) return;
@@ -544,11 +557,21 @@ export function appendCrossDesignOverlay(
const t = Math.abs(span) > 1e-9 ? (offset - a.offset_m) / span : 0;
return { offset_m: offset, elevation_m: a.elevation_m + (b.elevation_m - a.elevation_m) * t };
};
// 배수관 트림 경계에서는 끝단 표고를 **벽 이음선 상단점**에 맞춰 내린다 — 벽 높이가
// 관경+여유고로 고정된 뒤로는 설계 사면선 표고와 어긋나 사면선 끝이 벽에서 떨어진다
// (2026-08-21 사용자 ②). 사면 끝 접점 트림이 이긴 경계에는 적용하지 않는다.
const snap = (
p: { offset_m: number; elevation_m: number },
elevation: number | undefined,
wins: boolean,
) => (elevation != null && wins ? { offset_m: p.offset_m, elevation_m: elevation } : p);
const minIsCulvert = trim != null && Math.abs(effective.minOffset - trim.minOffset) < 1e-9;
const maxIsCulvert = trim != null && Math.abs(effective.maxOffset - trim.maxOffset) < 1e-9;
const clamp = (p: { offset_m: number; elevation_m: number }) =>
p.offset_m < effective.minOffset
? at(effective.minOffset)
? snap(at(effective.minOffset), trim?.minElevation, minIsCulvert)
: p.offset_m > effective.maxOffset
? at(effective.maxOffset)
? snap(at(effective.maxOffset), trim?.maxElevation, maxIsCulvert)
: p;
return [clamp(a), clamp(b)];
};
@@ -577,6 +600,20 @@ export function appendCrossDesignOverlay(
svg.append(seg);
}
// 배수관 측점 성토 사면 — 백엔드 설계선(1:1.2 고정) 대신 **배수관 레이아웃이 준 직선**
// 을 그린다. 벽 자리가 관 길이 맞춤으로 옮겨져도 노견 → 벽 이음선 상단점을 잇는 단일
// 각도가 유지된다(2026-08-21 사용자 ②, 성토 사면은 단일 각도).
for (const segment of [trim?.minSlope, trim?.maxSlope]) {
if (!segment) continue;
const seg = document.createElementNS(SVG_NS, "line");
seg.setAttribute("x1", String(x(segment.from.offset)));
seg.setAttribute("y1", String(toDisplayY(segment.from.elevation)));
seg.setAttribute("x2", String(x(segment.to.offset)));
seg.setAttribute("y2", String(toDisplayY(segment.to.elevation)));
seg.setAttribute("class", "b06-chart__design-cross");
svg.append(seg);
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
const edges = design.carriageway_edges;