Files
Aislo/B06_Section/B06_Section_UI_Cross_Culvert.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

262 lines
13 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_Culvert.ts
* 배수관 측점 횡단 카드의 배수관 세트(배관·기슭막이·보호공) **오버레이 그리기**.
*
* 기하 계산은 `B06_Section_UI_Cross_Culvert_Geom.ts`가 맡는다(700줄 제한 분리).
* 여기서는 계산 결과(`CulvertLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지.
*
* 그리는 순서 = 시공·계산 순서(2026-08-20 사용자 확정):
* ① 유출구측 구조물(기슭막이) → ② 유입구측 구조물(기슭막이 또는 집수정)
* → ③ 배관 → ④ 유출구측 보호공(돌붙임)
* ========================================================================== */
import {
PITCHING_THICKNESS_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
REVET_TRAP_TOP_M,
pipeWallThicknessM,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Geom";
import type { BasinShape, CulvertLayout, PipeEnd } from "./B06_Section_UI_Cross_Culvert_Geom";
// 기하 계산 진입점과 공개 상수·타입은 여기서 재수출한다 — B05(최소 토피)와 횡단 뷰가
// 이 모듈 경로로 이미 참조하고 있어 분리 후에도 import 경로를 바꾸지 않는다.
export { MIN_PIPE_COVER_M, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert_Geom";
export type {
BasinLayout,
BasinShape,
CulvertDesignTrim,
CulvertLayout,
EndFace,
} from "./B06_Section_UI_Cross_Culvert_Geom";
const SVG_NS = "http://www.w3.org/2000/svg";
function polygon(
points: Array<[number, number]>,
className: string,
tooltip: string,
): SVGPolygonElement {
const shape = document.createElementNS(SVG_NS, "polygon");
shape.setAttribute("points", points.map(([px, py]) => `${px},${py}`).join(" "));
shape.setAttribute("class", className);
const title = document.createElementNS(SVG_NS, "title");
title.textContent = tooltip;
shape.append(title);
return shape;
}
/** 배수관 세트 오버레이 — computeCulvertLayout 결과를 그린다. */
export function appendCulvertOverlay(
layer: SVGElement,
layout: CulvertLayout,
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
): void {
const { culvert, pipe, pipeCorners } = layout;
const diameter = culvert.diameter_m;
const roleLabel = (role: "inlet" | "outlet") => (role === "inlet" ? "유입" : "유출");
// ④ 유출구측 보호공(돌붙임) — 관까지 그린 뒤 마지막에 덮는다.
const drawPitching = (): void => {
const { top, base, lengthM, minLengthM } = layout.pitching;
if (top.length < 2 || base.length !== top.length) return;
layer.append(
polygon(
[
...top.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
...[...base]
.reverse()
.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
],
"b06-chart__culvert-pitching",
`유출부 보호공(돌붙임) — 성토사면 보호, 단면 연장 ${lengthM.toFixed(1)}m` +
` (최소 ${minLengthM.toFixed(1)}m = 낙차고×2 — 사방교본 교차 참조)` +
` · t=${PITCHING_THICKNESS_M}m(실무 L3=45)` +
` · 시작 변은 기슭막이 전면과 같은 경사, 윗면 시작점 = 관 하단 꼭짓점` +
` · 성토사면 경사길이 ${layout.fillSlope.lengthM.toFixed(2)}m` +
`(법정 5m ${layout.fillSlope.withinLimit ? "이내 충족" : "초과 — 구조물 보강 필요"})`,
),
);
};
// ①·② 구조물 — 유출측이 먼저 서고, 그 다음 유입측.
const wallsInOrder = [
...layout.walls.filter((w) => w.role === "outlet"),
...layout.walls.filter((w) => w.role === "inlet"),
];
for (const wall of wallsInOrder) {
// 합성 단면(하부 사다리꼴 + 상부 평행사변형) — 상단 배면이 사면선 접점(사용자 ①·②).
layer.append(
polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-revet",
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${wall.height.toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` +
`, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` +
` · 근입 ${REVET_EMBED_DEPTH_M.toFixed(2)}m(실무 기초콘크리트 H=0.5 — 법정 규정 없음)` +
(wall.lengthM ? ` · 연장 ${wall.lengthM.toFixed(1)}m` : ""),
),
);
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(내부 경계 — 사용자 스케치의 가운데 선):
// 상단 변 중간점(배면+0.45)에서 전면과 나란히 바닥으로 내려온다.
const joint = document.createElementNS(SVG_NS, "line");
joint.setAttribute("x1", String(x(wall.topBack.offset + wall.outward * REVET_TRAP_TOP_M)));
joint.setAttribute("y1", String(toDisplayY(wall.topBack.elevation)));
joint.setAttribute("x2", String(x(wall.outerOffset - wall.outward * REVET_THICKNESS_M)));
joint.setAttribute("y2", String(toDisplayY(wall.base - REVET_EMBED_DEPTH_M)));
joint.setAttribute("class", "b06-chart__culvert-stone");
layer.append(joint);
// 돌 해칭 — 큰 돌이 한 줄로 쌓인 **계류측 기운 띠**(전면 평행사변형)를 따라 쌓는다.
// 좌·우 벽은 outward 부호로 자동 반전된다. 벽 높이는 지형마다 달라지므로
// ① 배치 구간을 근입 바닥~상단 **전체**로 잡고 ② 벽 폴리곤 clip 안에만 그려
// 어떤 높이에서도 돌이 벽 밖으로 새지 않게 한다(2026-08-20 미스매치 정정).
const pixelsPerMeter = Math.abs(x(1) - x(0)) || 1;
const clipId = `b06-revet-clip-${wall.role}-${Math.round(wall.backOffset * 100)}`;
const clip = document.createElementNS(SVG_NS, "clipPath");
clip.setAttribute("id", clipId);
clip.append(
polygon(
wall.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"",
"",
),
);
const stoneGroup = document.createElementNS(SVG_NS, "g");
stoneGroup.setAttribute("clip-path", `url(#${clipId})`);
layer.append(clip, stoneGroup);
const embedBase = wall.base - REVET_EMBED_DEPTH_M;
const stoneSpan = wall.height + REVET_EMBED_DEPTH_M;
const stoneCount = Math.max(2, Math.round(stoneSpan / 0.45));
const stoneHeight = stoneSpan / stoneCount;
// 돌 중심선 = 전면 기운 띠(이음선~전면)의 중심선. 바닥은 근입 바닥까지 연장한다.
const centerBase = wall.outerOffset - wall.outward * (REVET_THICKNESS_M / 2);
const centerTop =
wall.topBack.offset + wall.outward * (REVET_TRAP_TOP_M + REVET_THICKNESS_M / 2);
// 화면 좌표 기준 기움 각(rect의 세로축을 경사축에 맞춘다).
const axisX = x(centerTop) - x(centerBase);
const axisY = toDisplayY(wall.topBack.elevation) - toDisplayY(embedBase);
const leanDegrees = (Math.atan2(axisX, -axisY) * 180) / Math.PI;
for (let i = 0; i < stoneCount; i += 1) {
const fraction = (i + 0.5) / stoneCount;
const centerOffset = centerBase + (centerTop - centerBase) * fraction;
const centerElevation = embedBase + stoneSpan * fraction;
const cx = x(centerOffset);
const cy = toDisplayY(centerElevation);
const stone = document.createElementNS(SVG_NS, "rect");
const widthPx = REVET_THICKNESS_M * pixelsPerMeter * 0.9;
const heightPx = stoneHeight * pixelsPerMeter * 0.86;
stone.setAttribute("x", String(cx - widthPx / 2));
stone.setAttribute("y", String(cy - heightPx / 2));
stone.setAttribute("width", String(widthPx));
stone.setAttribute("height", String(heightPx));
stone.setAttribute("rx", String(Math.min(widthPx, heightPx) * 0.3));
stone.setAttribute("transform", `rotate(${leanDegrees.toFixed(1)} ${cx} ${cy})`);
stone.setAttribute("class", "b06-chart__culvert-stone");
stoneGroup.append(stone);
}
}
if (layout.basin) {
const basin = layout.basin;
const shapeName = ((shape: BasinShape) =>
shape === "L" ? "ㄴ형" : shape === "U" ? "ㄷ형" : "I형")(basin.shape);
const reasonText =
basin.reason === "cut"
? "절토측 유입 — 자동 집수정"
: "성토측 유입이 원지반에 30% 이상 막혀 집수정으로 전환";
for (const part of basin.parts) {
layer.append(
polygon(
part.points.map((p) => [x(p.offset), toDisplayY(p.elevation)] as [number, number]),
"b06-chart__culvert-basin",
`유입 집수정(${shapeName}) ${part.kind === "floor" ? "바닥" : "벽"}` +
` — ${reasonText} · 내공 1.0m(실무 돌집수정), 벽은 기슭막이와 동일 단면`,
),
);
}
if (basin.cutLine) {
const cut = document.createElementNS(SVG_NS, "line");
cut.setAttribute("x1", String(x(basin.cutLine.from.offset)));
cut.setAttribute("y1", String(toDisplayY(basin.cutLine.from.elevation)));
cut.setAttribute("x2", String(x(basin.cutLine.to.offset)));
cut.setAttribute("y2", String(toDisplayY(basin.cutLine.to.elevation)));
// 절토선은 **공사 계획선**이다 — 설계선과 같은 보라 실선으로 그린다
// (2026-08-20 사용자 ②).
cut.setAttribute("class", "b06-chart__design-cross");
const cutTitle = document.createElementNS(SVG_NS, "title");
cutTitle.textContent = `집수정(${shapeName}) 설치 절토선(계획선) — 구조물이 원지반 안쪽에 들어가 절토 필요`;
cut.append(cutTitle);
layer.append(cut);
}
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String(x(basin.label.offset)));
// 라벨은 I형 벽 **하단** 바깥에 붙인다(2026-08-20 사용자) — 글자 높이만큼 내린다.
label.setAttribute("y", String(toDisplayY(basin.label.elevation) + 11));
label.setAttribute("text-anchor", "middle");
label.setAttribute("class", "b06-chart__culvert-label");
label.textContent = `집수정(${shapeName})`;
layer.append(label);
}
// ③ 관 — 축 법선 두께의 직사각형. 끝단면은 구조물 계류측 변에서 잘려 온다(Geom).
const { axis, normal } = layout.pipeAxis;
// 미세한 틈 방지: 양 끝을 축 방향으로 아주 조금만 밀어 넣는다. 벽 전면이 1:0.3으로
// 기울어 있어 크게 밀면 관 하단이 벽 **바깥으로 삐져나온다**(2026-08-20 사용자 ③
// 위치 어긋남 원인) — 렌더링 틈만 덮을 만큼(2㎝)으로 줄였다.
const OVERLAP_M = 0.02;
const pushOut = (corner: PipeEnd, dirSign: number): PipeEnd => ({
bottom: {
offset: corner.bottom.offset + dirSign * axis.offset * OVERLAP_M,
elevation: corner.bottom.elevation + dirSign * axis.elevation * OVERLAP_M,
},
top: {
offset: corner.top.offset + dirSign * axis.offset * OVERLAP_M,
elevation: corner.top.elevation + dirSign * axis.elevation * OVERLAP_M,
},
});
const inletFinal = pushOut(pipeCorners.inlet, -1);
const outletFinal = pushOut(pipeCorners.outlet, 1);
const kindLabel = culvert.pipe_kind ? `${culvert.pipe_kind} ` : "";
const wallThickness = pipeWallThicknessM(culvert.pipe_kind, diameter);
const tip =
`관매설 ${kindLabel}Φ${Math.round(diameter * 1000)}m/m, L=${pipe.lengthM}.0m` +
` · 매설 경사 ${pipe.slopePct.toFixed(1)}%(수평 가능)` +
` · 관 두께 ${(wallThickness * 1000).toFixed(1)}㎜(외경선 기준)` +
` · 최소 토피 ${culvert.min_cover_m.toFixed(1)}m(별표2 복토 교차 참조)`;
// 외경(관 두께 바깥) → 내경 순으로 2겹. 도면처럼 관벽 두께가 보이게 한다(사용자 ③).
const shifted = (corner: PipeEnd, delta: number): PipeEnd => ({
bottom: {
offset: corner.bottom.offset - normal.offset * delta,
elevation: corner.bottom.elevation - normal.elevation * delta,
},
top: {
offset: corner.top.offset + normal.offset * delta,
elevation: corner.top.elevation + normal.elevation * delta,
},
});
for (const [delta, cls] of [
[wallThickness, "b06-chart__culvert-pipe b06-chart__culvert-pipe--outer"],
[0, "b06-chart__culvert-pipe"],
] as const) {
const a = shifted(inletFinal, delta);
const b = shifted(outletFinal, delta);
layer.append(
polygon(
[
[x(a.bottom.offset), toDisplayY(a.bottom.elevation)],
[x(b.bottom.offset), toDisplayY(b.bottom.elevation)],
[x(b.top.offset), toDisplayY(b.top.elevation)],
[x(a.top.offset), toDisplayY(a.top.elevation)],
],
cls,
tip,
),
);
}
drawPitching();
}