Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts
T
eomsangdonandClaude Fable 5 0d5fba4400 feat(B06): 배수관 횡단도 세트(배관·기슭막이/집수정·보호공) 추가
B05에서 배치한 배수관 측점의 B06 횡단도에 배관·기슭막이(또는 집수정)·보호공을
분리 도형으로 그린다. 기존 도형(지반선·면적 밴드·포장·암 경계)은 손대지 않고,
계획선 표시 규칙만 세트와 정합을 맞춰 함께 정비했다.

- 백엔드: pipe_points.json 정본(facility=="pipe")에 레지스트리 기본값을 채워
  chainage ±0.02m로 맞는 횡단에 culvert 키를 얹는다(Engine_Culvert 신규, Router의
  상세조회·재생성 두 경로에서 호출). 횡단 dict는 자유형이라 Pydantic 변경 없음.
- 배관: 유입 invert = min(노견 아래 원지반, 노면고 − 관경 − 토피 0.5), 유출 앵커는
  성토사면이 지반과 만나는 사면 끝(성토사면 길이 5m 한계 — 별표2 Ⅰ.2.차.(3)).
  역경사는 수평 클램프, 길이는 m 단위 올림. 단면은 축 법선 방향 직사각형이고
  외경선(실선)+내경선(점선) 2겹으로 관벽 두께를 보인다(파형강관 2.7T — KS D 3590).
  끝단면은 구조물의 계류측 변을 복사해 자른다.
- 기슭막이: 배면 수직 사다리꼴 + 전면 평행사변형 띠(1:0.3, t=0.45 — 실무 L3=45)의
  합성 단면. 높이는 이음선 상단점이 성토 사면선과 만나는 접점까지, 하한 관경+0.45,
  상한은 형태별 한계(찰 3.0/메 2.0 — 교본 7-3). 근입 0.5m(실무 기초콘크리트 H=0.5).
  돌 해칭은 벽 폴리곤 clipPath 안에만 그려 어떤 높이에서도 벽 밖으로 새지 않는다.
- 집수정: 절토측 유입이거나 원지반이 관 단면의 30% 이상을 막으면 자동 전환.
  I형(벽)·ㄴ형(+바닥)·ㄷ형(+반대측 막음), 기본 ㄴ형·내공 1.0m. 구조물이 원지반
  안쪽에 박히면 절토선을 공사 계획선(보라 실선)으로 긋는다. 라벨은 I형 벽 하단.
- 보호공: 시작 윗점 = 관 하단 꼭짓점, 시작 변은 기슭막이 전면과 같은 경사.
  성토경사를 따라 원지반까지 내려간 뒤, 최소 길이(낙차고×2)를 채울 때까지 원지반을
  따라 더 간다.
- 계획선 표시: 원지반과 차이 없는 구간은 그리지 않되(보라 점선 폐지), 노면·노견·
  측구부터 사면 1차 접점까지는 항상 그린다. 측구는 노견 바깥에 붙으므로 보호 범위를
  측구 폭만큼 넓히고 접점 탐색도 그 바깥에서 시작한다. 배수관 측점은 벽 접점에서 트림.
- B05: 배수관 측점 계획고를 지반고 + 관경 + 토피 0.5m 아래로 내리지 못하게 막고
  경고 토스트를 띄운다.
- 700줄 제한: 오버레이 파일이 919줄이 되어 기하(_Geom 678줄)와 그리기(247줄)로
  분리했다. import 경로는 재수출로 유지해 B05·Cross_View는 건드리지 않았다.

근거 미확정(코드 주석·툴팁에 명시): 토피 0.5m(별표2 교량·암거 복토 50㎝ 교차 참조),
기슭막이 근입 0.5m(실무값), 흄관·VR관 관벽 두께(관경÷12 — KS F 4403 미확보).

검증: tsc, ruff, pytest 148 passed(신규 test_b06_culvert_set.py 6건), 헤드 브라우저 —
4+4.3·13+15.7 세트 표시, 보호공 시작점과 관 하단 꼭짓점 간격 0.6px 이내·시작 변
기울기 실측 0.223(= 0.3·H/(H+0.5) 이론값과 일치)·최소 길이 5.0m 확보, 계획선
세그먼트 연결 틈 0개(8·9·10·12·4+4.3). ⚠ 13+15.7 성토사면 5.03m로 법정 5m를
0.03m 초과 — 툴팁에 표기하고 접점 보간 여부는 사용자 판단 대기.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:43:51 +09:00

730 lines
35 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_Geom.ts
* 배수관 세트(배관·기슭막이·보호공) **기하 계산** — 그리기(`_Cross_Culvert.ts`)와 분리.
*
* 백엔드(`B06_Section_Engine_Culvert`)가 `pipe_points.json` 정본 + 레지스트리 기본값으로
* 만든 `section.culvert` 제원을 좌표로 옮기기만 한다 — 여기서 치수를 새로 정하지 않는다.
*
* 배치 규칙 — **실무 횡단도 4장 기준으로 재정립**(2026-08-20 사용자 제공, 울진 계열
* `관매설 Φ800~1000 · 기슭막이 유입 찰/유출 메 H=1.5 · 돌붙임(메붙임) ㎡` 주기):
* · 기슭막이 2기 — **도로면 양 가장자리**(유입 = 절토측 측구부, 유출 = 성토측 노견
* 바깥 = 성토사면 최상단)에 선다. 형상은 사면 쪽으로 **기운 평행사변형**(전면 1:0.3,
* 두께 0.45m), 상단 ≈ 노면고.
* · 배관 — 두 벽 사이(도로 하부)만 직선으로 지난다. 단면 항상 직사각형(축 법선 두께),
* 끝단면은 벽 전면과 평행하게 마감. 수평 가능. 길이 표기는 m 단위 올림.
* · 보호공(돌붙임) — **관 하단 꼭짓점**에서 시작해 성토사면 경사(1:n)를 따라 원지반까지,
* 최소 보호공 길이(낙차고×2)를 채울 때까지 지반을 따라 더 간다.
* · 유입측 — 수평 보호공 없음. 측구 접속은 설계선(표준단면 측구)이 이미 표현한다.
* · 성토 경사 — 벽 전면과 교차된 이후는 끊는다(designTrim — 벽·보호공이 대신한다).
*
* 유입 = 상단측(uphill_side), 유출 = 하단측. 유입구가 "집수정"이면 그쪽 기슭막이는
* 그리지 않고 집수정 단면(I/ㄴ/ㄷ형)으로 바꾼다.
* ========================================================================== */
import type {
CrossSection,
CulvertSet,
CulvertSideSpec,
SectionSample,
} from "./B06_Section_Api_Fetch";
/** 관 위 최소 토피(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) — 관종별. 지식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;
}
/** 배수관 세트 기하 계산. 부족한 입력이면 null — 그리기와 분리(설계선 트림이 먼저 쓴다). */
export function computeCulvertLayout(
section: CrossSection,
groundSamples: SectionSample[],
): CulvertLayout | null {
const culvert = section.culvert;
if (!culvert) return null;
const groundAt = groundInterpolator(groundSamples);
if (!groundAt) return null;
const edges = section.design?.road_edges;
if (!edges) return null;
const designAt = designInterpolator(section.design?.design_line);
const sampleOffsets = groundSamples.map((sample) => sample.offset_m ?? 0);
const minSample = Math.min(...sampleOffsets);
const maxSample = Math.max(...sampleOffsets);
if (!(maxSample > minSample)) return null;
// 좌표 규약: +offset = 좌측(화면 왼쪽). 상단측이 유입이다(미상이면 좌측 폴백).
const uphill = section.uphill_side ?? "left";
const sideInfo = (side: "left" | "right") => ({
edge: side === "left" ? edges.left : edges.right,
outward: side === "left" ? 1 : -1,
limit: side === "left" ? maxSample : minSample,
});
const inletInfo = sideInfo(uphill === "left" ? "left" : "right");
const outletInfo = sideInfo(uphill === "left" ? "right" : "left");
// 유입 invert: 유입 노견 아래 원지반. 절토측이면 "노면 − 관경 − 토피"가 상한
// (관 상단 + 토피가 노면 안에 들어가는 최고 자리 — B05 하향 차단식과 동일).
const diameter = culvert.diameter_m;
const invertCap = (edge: { offset_m: number; elevation_m: number }): number =>
(designAt ? designAt(edge.offset_m) : edge.elevation_m) - diameter - MIN_PIPE_COVER_M;
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";
const inletIsCut =
mode === "both_cut" ||
(mode === "left_cut" && inletSideName === "left") ||
(mode === "right_cut" && inletSideName === "right");
// 성토측 유입이라도 관 앞 원지반이 관 단면의 30% 이상을 막으면 집수정으로 바꾼다
// (2026-08-20 사용자 확정 — 자연 유입이 불가해 옆도랑 물을 받아야 하는 형상).
const inletFrontOffset =
inlet.offset + inletInfo.outward * (REVET_THICKNESS_M * 2 + REVET_LEAN_RATIO * diameter);
const blockedRatio = (groundAt(inletFrontOffset) - inletInvert) / Math.max(diameter, 1e-6);
const inletBlocked = !inletIsCut && blockedRatio >= 0.3;
const basinReason: BasinLayout["reason"] | null = inletIsCut
? "cut"
: inletBlocked
? "blocked"
: null;
// 기슭막이 합성 단면(사용자 ①·②): 하부 = 배면 수직·전면 1:0.3 **사다리꼴**(관이
// 지나는 관경 높이), 상부 = 전·배면이 나란히 기운 **평행사변형**. 상단 배면 꼭짓점이
// 성토 사면선(설계선)과 만나는 접점이 되도록 높이를 정한다 — 사면선은 거기서 끊긴다.
const walls: WallLayout[] = [];
let basin: BasinLayout | null = null;
/** 집수정이 서면 관 유입단을 내공 안으로 옮긴다(접속 표현). */
let basinPipeEnd: OffsetPoint | null = null;
/** 관 끝단 마감면 — 구조물 계류측 변 복사(2026-08-20 사용자 ③). */
const endFaces: { inlet: EndFace | null; outlet: EndFace | null } = {
inlet: null,
outlet: null,
};
let trimMin = Number.NEGATIVE_INFINITY;
let trimMax = Number.POSITIVE_INFINITY;
const buildWall = (
spec: CulvertSideSpec,
anchor: OffsetPoint,
outward: number,
forceBasinReason: BasinLayout["reason"] | null,
): WallLayout | null => {
const reason: BasinLayout["reason"] | null =
forceBasinReason ?? (spec.structure === "집수정" ? "cut" : null);
if (reason) {
// 집수정 = 기슭막이와 같은 **평행사변형 벽**(+ 형식에 따라 바닥·반대측 막음).
// 형식 기본값 = ㄴ(L)형(2026-08-20 사용자 확정). 실무 내공 1.0m(울진 돌집수정).
const shape: BasinShape = "L";
const innerWidth = 1.0;
const floorThickness = REVET_THICKNESS_M;
const roadTop = designAt ? designAt(anchor.offset) : anchor.elevation;
// 벽 높이: **상단 도로측 꼭지점이 성토 경사선(설계선)과 만나는 교점**까지
// (2026-08-20 사용자 — 기슭막이와 같은 접점 규칙). 하한은 관경 + 0.45.
const basinMinHeight = diameter + REVET_THICKNESS_M;
let wallHeight = Math.max(roadTop - anchor.elevation, basinMinHeight);
if (designAt) {
const wallBaseProbe = anchor.offset + outward * innerWidth;
for (let h = basinMinHeight; h <= 4.0 + 1e-6; h += 0.05) {
// 상단 도로측 꼭지점(기움 반전 — 상단이 도로측으로 물러난다).
const topInner = wallBaseProbe - outward * REVET_LEAN_RATIO * h;
wallHeight = h;
if (anchor.elevation + h >= designAt(topInner)) break;
}
wallHeight = Math.max(wallHeight, basinMinHeight);
}
// 벽 단면은 기슭막이와 동일(두께 0.45). 기움은 **수직 기준 반전** — 상단이
// 도로측(내공 쪽)으로 1:0.3 물러난다(2026-08-20 사용자 정정).
const wallOf = (baseOffset: number, dir: number): OffsetPoint[] => {
const topShift = -dir * REVET_LEAN_RATIO * wallHeight;
return [
{ offset: baseOffset, elevation: anchor.elevation - floorThickness },
{ offset: baseOffset + topShift, elevation: anchor.elevation + wallHeight },
{
offset: baseOffset + topShift + dir * REVET_THICKNESS_M,
elevation: anchor.elevation + wallHeight,
},
{
offset: baseOffset + dir * REVET_THICKNESS_M,
elevation: anchor.elevation - floorThickness,
},
];
};
// I형 벽 = 계류측(도로 바깥) 면 — 관 유입단에서 내공 1.0m 떨어져 선다.
const wallBase = anchor.offset + outward * innerWidth;
const parts: BasinLayout["parts"] = [{ kind: "wall", points: wallOf(wallBase, outward) }];
// ㄴ형 = I형 + 바닥판. 바닥은 **아래가 짧은 사다리꼴**(윗변이 길고 아랫변이
// 짧다 — 벽과 같은 1:0.3 물매가 양 끝에 붙는다). 위치는 **I형 벽 기준 반대측**
// = 도로측으로 뻗는다(2026-08-20 사용자 확정).
// 바닥은 **I형 벽의 바깥(계류측) 변에 좌측 변을 맞대고** 그 너머로 뻗는다
// (2026-08-20 사용자 확정). 벽이 기울어 있으므로 바닥 좌측 변도 **벽 바깥면 선을
// 그대로 따라** 기울여야 틈 없이 붙는다(높이마다 벽 x가 달라진다).
const wallOuterAt = (elevation: number): number => {
const bottom = anchor.elevation - floorThickness;
const t = (elevation - bottom) / (wallHeight + floorThickness);
return wallBase + outward * REVET_THICKNESS_M - outward * REVET_LEAN_RATIO * wallHeight * t;
};
const floorTopInner = wallOuterAt(anchor.elevation);
const floorBottomInner = wallOuterAt(anchor.elevation - floorThickness);
const floorOuter = floorTopInner + outward * (innerWidth + REVET_THICKNESS_M);
const floorTaper = REVET_LEAN_RATIO * floorThickness;
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 },
],
});
// ㄷ형이면 반대측(도로측) 막음벽을 하나 더 세운다 — 지금은 L형 기본이라 미사용.
if ((shape as BasinShape) === "U") {
parts.push({ kind: "wall", points: wallOf(anchor.offset, -outward) });
}
// ㄴ·ㄷ형이 원지반 **안쪽**에 박히면 그만큼 절토가 필요하다(2026-08-20 사용자 ①).
// 구조물 바깥 끝 상단에서 표준단면 절토경사(1:n)로 원지반과 만나는 점까지 긋는다.
let basinCut: BasinLayout["cutLine"] = null;
if ((shape as BasinShape) !== "I") {
const outerTop = { offset: floorOuter, elevation: anchor.elevation };
if (groundAt(floorOuter) > anchor.elevation + 0.05) {
const cutRatio = section.design?.cut_slope_ratio ?? 1.0;
for (let h = 0.05; h <= 20; h += 0.05) {
const probe = floorOuter + outward * cutRatio * h;
if (groundAt(probe) <= anchor.elevation + h) {
basinCut = {
from: outerTop,
to: { offset: probe, elevation: anchor.elevation + h },
};
break;
}
}
}
}
basin = {
shape,
parts,
// 라벨 자리 = **I형 벽 하단**(2026-08-20 사용자). 상단에 두면 노면·계획선과
// 겹친다 — 벽 바닥 변 중앙에 두고 그리기 쪽에서 아래로 내려 붙인다.
label: {
offset: wallBase + outward * (REVET_THICKNESS_M / 2),
elevation: anchor.elevation - floorThickness,
},
reason,
cutLine: basinCut,
};
// 성토 사면선은 **벽 상단 도로측 꼭지점(교점)**에서 끊는다 — 바닥 끝을 기준으로
// 두면 사면선이 집수정을 뚫고 들어간다(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);
// 관 유입 끝단 마감면 = 집수정 벽의 **계류측 변**을 그대로 복사(사용자 ③).
const faceBottom = {
offset: wallBase + outward * REVET_THICKNESS_M,
elevation: anchor.elevation - floorThickness,
};
const faceTop = {
offset: wallBase - outward * REVET_LEAN_RATIO * wallHeight + outward * REVET_THICKNESS_M,
elevation: anchor.elevation + wallHeight,
};
endFaces[spec.role] = {
base: faceBottom,
direction: {
offset: faceTop.offset - faceBottom.offset,
elevation: faceTop.elevation - faceBottom.elevation,
},
};
// 관은 집수정 내공을 가로질러 **안쪽 벽면까지** 물린다 — 벽 몸통 중간에서 끊기면
// 접속이 어긋나 보인다(2026-08-20 사용자 ③).
basinPipeEnd = {
offset: anchor.offset + outward * innerWidth,
elevation: anchor.elevation,
};
return null;
}
// 형상(사용자 스케치 확정 — 좌측 벽 기준, 우측은 반전): **배면(도로측) 수직**,
// **전면(계류측)은 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);
// 형태별 법정·교본 높이 한계(찰 3.0 / 메 2.0 — 돌쌓기.md §1)를 넘지 못한다.
height = Math.min(height, revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const topBack: OffsetPoint = {
offset: anchor.offset,
elevation: anchor.elevation + height,
};
// 상단 변: 사다리꼴 상단(0.45) + 띠 상단(0.45). 이음선은 상단 변 중간점(topJoint)
// 에서 1:0.3으로 바닥까지 — 사다리꼴(수직 배면, 상단 0.45 → 하단 0.45+0.3H)과
// 평행사변형 띠(폭 0.45)가 진짜 사다리꼴·평행사변형이 된다.
const topFront = topJoint + outward * REVET_THICKNESS_M;
const jointBase = topJoint + outward * REVET_LEAN_RATIO * height;
const frontBase = jointBase + outward * REVET_THICKNESS_M;
// 근입: 벽 바닥은 원지반 아래 REVET_EMBED_DEPTH_M만큼 묻힌다(흙막이 "본바닥에
// 기초" 원칙 + 실무 기초콘크리트 H=0.5). 바닥 꼭짓점 2개만 내리고 상단·이음선·
// 관 위치는 그대로 — 관은 여전히 원지반 높이로 지난다.
const embedBase = anchor.elevation - REVET_EMBED_DEPTH_M;
const wall: WallLayout = {
role: spec.role,
form: spec.revet_form ?? null,
lengthM: spec.revet_length_m ?? null,
backOffset: anchor.offset,
outerOffset: frontBase,
base: anchor.elevation,
height,
outward,
topBack,
points: [
{ offset: anchor.offset, elevation: embedBase },
topBack,
{ offset: topFront, elevation: anchor.elevation + height },
{ offset: frontBase, elevation: embedBase },
],
};
walls.push(wall);
// 성토 사면선은 **이음선 상단점**에서 끊는다(교차점 = 트림 경계 — 사용자 스케치).
if (outward > 0) trimMax = Math.min(trimMax, topJoint);
else trimMin = Math.max(trimMin, topJoint);
// 관 끝단 마감면 = 기슭막이의 **계류측 변**(전면)을 그대로 복사(사용자 ③).
endFaces[spec.role] = {
base: { offset: frontBase, elevation: embedBase },
direction: {
offset: topFront - frontBase,
elevation: anchor.elevation + height - embedBase,
},
};
return wall;
};
buildWall(culvert.inlet, inlet, inletInfo.outward, basinReason);
// 유출 벽 자리: 사면 끝에서는 사면선이 벽 상단보다 낮아 교차점이 성립하지 않는다.
// 관 축을 따라 노견 쪽으로 되짚으며 **이음선 상단점(base + 하한높이)이 사면선과
// 만나는 지점**을 찾는다 — 관·보호공 끝(anchor)은 그대로 두고 벽만 그 자리에 선다.
let outletWallAnchor = outletAnchor;
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;
}
}
}
const outletWall = buildWall(culvert.outlet, outletWallAnchor, outletInfo.outward, null);
// 유출 벽이 사면 끝보다 안쪽으로 당겨졌으면 **관 끝도 그 자리에 맞춘다** — 관만 사면
// 끝까지 남으면 벽과 떨어져 마감면 교차가 실패한다(2026-08-20 사용자 ② 원인).
if (outletWall && Math.abs(outletWallAnchor.offset - outletAnchor.offset) > 0.05) {
const runW = outletWallAnchor.offset - inlet.offset;
const riseW = outletWallAnchor.elevation - inlet.elevation;
const lenW = Math.hypot(runW, riseW);
if (lenW > 0.5) {
const scaleW = Math.ceil(lenW - 1e-6) / lenW;
outlet.offset = inlet.offset + runW * scaleW;
outlet.elevation = inlet.elevation + riseW * scaleW;
lengthM = Math.ceil(lenW - 1e-6);
}
}
// ── 관 단면 꼭짓점 — 끝단면을 구조물 계류측 변에 맞춰 자른다(2026-08-20 사용자 ③).
// 보호공 시작점이 **관 하단 꼭짓점**을 그대로 써야 하므로 여기서 확정한다.
const pipeStart = basinPipeEnd ?? inlet;
const runP = outlet.offset - pipeStart.offset;
const riseP = outlet.elevation - pipeStart.elevation;
const axisLength = Math.hypot(runP, riseP) || 1;
const axis: OffsetPoint = { offset: runP / axisLength, elevation: riseP / axisLength };
let normal: OffsetPoint = { offset: -axis.elevation, elevation: axis.offset };
if (normal.elevation < 0) normal = { offset: -normal.offset, elevation: -normal.elevation };
const topAnchor: OffsetPoint = {
offset: pipeStart.offset + normal.offset * diameter,
elevation: pipeStart.elevation + normal.elevation * diameter,
};
const endCorners = (role: "inlet" | "outlet", endPoint: OffsetPoint): PipeEnd => {
const fallback: PipeEnd = {
bottom: endPoint,
top: {
offset: endPoint.offset + normal.offset * diameter,
elevation: endPoint.elevation + normal.elevation * diameter,
},
};
const face = endFaces[role];
if (!face) return fallback;
const bottom = intersect(face.base, face.direction, pipeStart, axis);
const top = intersect(face.base, face.direction, topAnchor, 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;
return strayed ? fallback : { bottom, top };
};
const pipeCorners = {
inlet: endCorners("inlet", pipeStart),
outlet: endCorners("outlet", outlet),
};
// 보호공(돌붙임) — 유출측. 2026-08-20 사용자 ①·②:
// ① 시작 변은 **기슭막이 전면과 같은 경사**(밑면을 벽 전면 방향으로 민다),
// 윗면 시작점 = **관 하단 꼭짓점**(관 밑면과 한 점에서 만난다).
// ② 성토사면 경사(1:n)로 원지반까지 내려간 뒤에도 **최소 보호공 길이**
// (낙차고×2 — 백엔드 `apron_length_m`)를 채울 때까지 원지반을 따라 더 간다.
// 끝단 윗점이 원지반 위에 놓인다.
const pitchingTop: OffsetPoint[] = [];
const pitchingBase: OffsetPoint[] = [];
let pitchingLength = 0;
const minPitchingM = culvert.outlet.apron_length_m ?? 0;
if (outletWall) {
const fillRatio = section.design?.fill_slope_ratio ?? 1.2;
// 밑면 이동 방향 = 기슭막이 전면 변과 나란한 **아래 방향** 단위벡터 × 두께.
let shift: OffsetPoint = { offset: 0, elevation: -PITCHING_THICKNESS_M };
const face = endFaces.outlet;
if (face) {
const faceLength = Math.hypot(face.direction.offset, face.direction.elevation);
if (faceLength > 1e-9) {
shift = {
offset: (-face.direction.offset / faceLength) * PITCHING_THICKNESS_M,
elevation: (-face.direction.elevation / faceLength) * PITCHING_THICKNESS_M,
};
}
}
const start = pipeCorners.outlet.bottom;
pitchingTop.push(start);
let previous = start;
const step = 0.25;
for (let t = step; t <= 30 + 1e-9; t += step) {
const offset = start.offset + outletWall.outward * t;
const slopeElevation = start.elevation - t / fillRatio;
const groundElevation = groundAt(offset);
const point: OffsetPoint = {
offset,
elevation: Math.max(slopeElevation, groundElevation),
};
pitchingLength += Math.hypot(
point.offset - previous.offset,
point.elevation - previous.elevation,
);
pitchingTop.push(point);
previous = point;
// 지반에 닿았고 최소 길이도 채웠으면 마감 — 아직이면 지반을 따라 계속 간다.
if (slopeElevation <= groundElevation && pitchingLength >= minPitchingM - 1e-6) break;
}
for (const point of pitchingTop) {
pitchingBase.push({
offset: point.offset + shift.offset,
elevation: point.elevation + shift.elevation,
});
}
}
return {
culvert,
pipe: { inlet: pipeStart, outlet, lengthM, slopePct },
pipeAxis: { axis, normal },
pipeCorners,
walls,
fillSlope: {
lengthM: fillSlopeLength,
withinLimit: fillSlopeLength <= FILL_SLOPE_MAX_LENGTH_M + 1e-6,
},
pitching: {
top: pitchingTop,
base: pitchingBase,
lengthM: pitchingLength,
minLengthM: minPitchingM,
},
basin,
designTrim: walls.length || basin ? { minOffset: trimMin, maxOffset: trimMax } : null,
};
}