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>
This commit is contained in:
2026-08-20 19:43:51 +09:00
co-authored by Claude Fable 5
parent a6197cdc46
commit 0d5fba4400
9 changed files with 1439 additions and 15 deletions
+45 -3
View File
@@ -8,15 +8,18 @@
* 거친다. 그래프·테이블·유토곡선이 같은 X 매핑을 쓰는 규칙은 그대로다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
} from "../B06_Section/B06_Section_UI_Longitudinal";
import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
import { MIN_PIPE_COVER_M } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import {
adjustStation,
planElevationAt,
setCurveRadius,
shiftSegment,
type AlignmentBase,
@@ -36,7 +39,11 @@ import {
STATION_SPACING_PX,
TABLE_ROW_COUNT,
} from "./B05_Profile_UI_Profile_Layout";
import { irregularStationId, type IrregularStation } from "./B05_Profile_UI_IrregularStations";
import {
irregularStationId,
isPipeStation,
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "./B05_Profile_UI_Profile_Structures";
import { buildStructureLane, STRUCTURE_LANE_HEIGHT_PX } from "./B05_Profile_UI_Structures_Marks";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
@@ -263,6 +270,39 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙).
if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight));
if (alignment) {
// 배수관 측점 계획고 하향 차단(2026-08-19): 관은 지반선상 매설이라(교본 8장)
// 계획고가 지반고 + 관경 + 최소 토피 아래로 내려가면 관이 노면을 뚫는다.
// 내림(▼)만 막고 올림(▲)·다른 측점은 그대로 둔다 — 판단은 사용자 몫, 한계만 지킨다.
const groundAt = (chainageM: number): number | null => {
if (!base || !base.chainage.length) return null;
const { chainage: xs, ground: ys } = base;
if (chainageM <= xs[0]) return ys[0];
if (chainageM >= xs[xs.length - 1]) return ys[ys.length - 1];
for (let i = 1; i < xs.length; i += 1) {
if (chainageM > xs[i]) continue;
const span = xs[i] - xs[i - 1];
if (span <= 0) return ys[i];
return ys[i - 1] + (ys[i] - ys[i - 1]) * ((chainageM - xs[i - 1]) / span);
}
return ys[ys.length - 1];
};
const blocksPipeLowering = (chainageM: number, delta: number): boolean => {
if (delta >= 0) return false;
const pipe = irregularStations.find(
(entry) => isPipeStation(entry) && Math.abs(entry.chainage_m - chainageM) < 0.02,
);
if (!pipe) return false;
const ground = groundAt(chainageM);
if (ground === null) return false;
const diameterM = (pipe.diameterMm ?? 1000) / 1000;
const minPlan = ground + diameterM + MIN_PIPE_COVER_M;
if (planElevationAt(alignment, chainageM) + delta >= minPlan - 1e-6) return false;
showToast(
`배수관 측점 — 관 매설 최소 높이(지반 + 관경 ${diameterM.toFixed(1)}m + 토피 ${MIN_PIPE_COVER_M.toFixed(1)}m)에 도달해 더 내릴 수 없습니다.`,
"warning",
);
return true;
};
chartWrap.append(
createEditOverlay({
alignment,
@@ -274,8 +314,10 @@ export function renderProfile(ctx: ProfileRenderContext): void {
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
onStation: (chainage, delta) =>
base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)),
onStation: (chainage, delta) => {
if (!base || blocksPipeLowering(chainage, delta)) return;
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta));
},
onSegment: (segment, delta) =>
base && ctx.applyEdits(shiftSegment(base, store.edits(), segment, delta)),
onResetStation: (chainage) => store.resetStation(chainage),
+31
View File
@@ -185,10 +185,41 @@ export interface LongitudinalSection {
profile_alignment?: unknown;
}
/**
* 배수관 세트의 유입/유출 한쪽 부속 제원 (백엔드 `B06_Section_Engine_Culvert` 산출).
* 구조가 "집수정"이면 기슭막이·보호공 필드가 없다 — 라벨만 쓴다(집수정 단면은 후속).
*/
export interface CulvertSideSpec {
role: "inlet" | "outlet";
structure: string;
revet_form?: string | null;
revet_height_m?: number | null;
revet_length_m?: number | null;
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
face_slope?: number;
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
apron_length_m?: number;
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
apron_thickness_m?: number;
}
/** 배수관 측점의 세트(배관·기슭막이·보호공) 제원 — 횡단 카드 오버레이 입력. */
export interface CulvertSet {
type: "pipe";
pipe_kind: string | null;
diameter_m: number;
/** 관 위 최소 토피(m) — 별표2 교량·암거 복토 50㎝ 교차 참조. B05 하향 차단 기준. */
min_cover_m: number;
inlet: CulvertSideSpec;
outlet: CulvertSideSpec;
}
export interface CrossSection extends SectionStation {
samples: SectionSample[];
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
design?: CrossDesign;
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
culvert?: CulvertSet;
}
export interface SectionDetailResponse {
+189
View File
@@ -0,0 +1,189 @@
"""B06 배수관 세트(배관·기슭막이·보호공) 횡단도 정보.
B05 배수유역도가 찍은 관 지점(`pipe_points.json` = 배관 정본)을 읽어, 같은 누가거리의
횡단면에 그림을 그릴 수 있을 만큼의 제원을 `culvert` 키로 얹는다. 값을 새로 만들지 않고
**정본 + 레지스트리 기본값**만 조합한다 — 상수 사본을 두면 B05 폼과 갈라진다.
근거(지식DB):
· 관은 **지반선상 매설** — 뜨게 매설은 부적합 (교본 8장, 그림 8-3·8-9)
· 관 지름 1,000㎜ 이상(현지여건 800㎜ 이상) — 별표2 Ⅰ.2.사.(2)
· 유입구 = 계곡부 기슭막이 / 계통적 옆도랑은 집수정, 유출구 = 기슭막이 (교본 8장 3·4절)
· 기슭막이 계획비탈 1:0.3~1:0.5 (사방기술교본), 돌쌓기 전면 1:0.3 (교본 7-3)
· 유출구부터 원지반까지 도수로·물받이 설치 의무 — 별표2 Ⅰ.2.사.(2)
⚠ 아래 세 상수는 **임도 확정값이 아니다**. 지식DB에 임도 기준이 없어 교차 참조로
채택했고(2026-08-19 사용자 확정), 임도 기준이 확보되면 그 값으로 교체한다.
"""
from __future__ import annotations
import json
import logging
from functools import lru_cache
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from common_util.common_util_drainage_pipes import PIPE_FACILITY_PIPE, parse_pipe_points
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
DRAINAGE_EDITS_DIRNAME,
DRAINAGE_PIPE_POINTS_FILENAME,
)
logger = logging.getLogger(__name__)
# 관 지점과 횡단 측점을 같은 자리로 볼 허용 오차(m). 정본이 누가거리를 0.01m로 끊어 쓴다.
_CHAINAGE_TOLERANCE_M = 0.02
# ⚠ 교차 참조 ① 관 위 최소 토피(m). 임도 배수관 토피 규정이 지식DB에 없어 별표2
# 교량·암거 "복토 시 흙 두께 50㎝ 이상"을 끌어왔다. B05 계획고 하향 차단 기준이다.
MIN_PIPE_COVER_M = 0.5
# ⚠ 교차 참조 ② 보호공(물받이) 길이 = 낙차고(기슭막이 높이) × 배수.
# 사방기술교본 바닥막이 물받이 "길이 = 낙차고의 2~3배" 중 2배 채택.
APRON_LENGTH_FACTOR = 2.0
# 보호공 두께(m) — 사용자 확정 0.45 (2026-08-20, 실무 돌붙임 L3=45와 정합).
# 사방교본 물받이 "두께 1m 내외"는 바닥막이용 교차 참조라 채택하지 않았다.
APRON_THICKNESS_M = 0.45
# 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3) = 사방교본 계획비탈
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
REVET_FACE_SLOPE = 0.3
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
# 유입구 구조 선택지 — 레지스트리 `pipe.inlet_type` choices와 같은 문자열이다.
INLET_STRUCTURE_BASIN = "집수정"
def pipe_points_file(project_root: Path) -> Path:
"""프로젝트 저장소 안의 관 지점 정본 경로."""
return (
project_root
/ "B04_PreProcess"
/ DRAINAGE_CACHE_DIRNAME
/ DRAINAGE_EDITS_DIRNAME
/ DRAINAGE_PIPE_POINTS_FILENAME
)
@lru_cache(maxsize=1)
def _registry_pipe_defaults() -> dict[str, Any]:
"""레지스트리 `pipe` 타입 옵션의 기본값 묶음.
저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는
기본값과 항상 같은 값이 그림에 들어간다.
"""
pipe_type = structure_type_map().get("pipe")
if pipe_type is None:
return {}
return {option.key: option.default for option in pipe_type.options}
def _number(value: Any, fallback: float | None) -> float | None:
"""숫자 옵션 하나를 float로 정리한다. 문자열 저장분(관경 "1000")도 받는다."""
if isinstance(value, bool):
return fallback
if isinstance(value, (int, float)):
return float(value)
if isinstance(value, str):
try:
return float(value.strip())
except ValueError:
return fallback
return fallback
def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
"""유입("inlet")·유출("outlet") 한쪽의 부속 제원.
구조가 집수정이면 기슭막이·보호공을 만들지 않는다 — 집수정 단면은 후속 작업이라
화면은 라벨만 쓴다(2026-08-19 사용자 확정).
"""
structure = options.get(f"{side}_type") or defaults.get(f"{side}_type") or "기슭막이"
spec: dict[str, Any] = {"role": side, "structure": str(structure)}
if spec["structure"] == INLET_STRUCTURE_BASIN:
return spec
height = _number(
options.get(f"{side}_revet_height_m"),
_number(defaults.get(f"{side}_revet_height_m"), None),
)
length = _number(
options.get(f"{side}_revet_length_m"),
_number(defaults.get(f"{side}_revet_length_m"), None),
)
form = options.get(f"{side}_revet_form") or defaults.get(f"{side}_revet_form")
spec.update(
{
"revet_form": str(form) if form else None,
"revet_height_m": height,
"revet_length_m": length,
"face_slope": REVET_FACE_SLOPE,
}
)
# 보호공은 기슭막이 바닥의 세굴 방지 구조 — 낙차고(기슭막이 높이)에 종속한다.
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
spec["apron_thickness_m"] = APRON_THICKNESS_M
return spec
def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""관 1개소의 세트 제원(관 + 유입·유출 기슭막이 + 보호공)."""
defaults = _registry_pipe_defaults()
values = dict(options or {})
diameter_mm = _number(
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
)
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
return {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"min_cover_m": MIN_PIPE_COVER_M,
"inlet": _side_spec(values, defaults, "inlet"),
"outlet": _side_spec(values, defaults, "outlet"),
}
def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
"""관 지점 정본을 읽어 누가거리별 세트 제원을 돌려준다.
노선 지문은 보지 않는다 — 횡단면 자체가 현 노선에서 만들어진 것이라, 지문이
어긋난 옛 관 지점은 매칭될 측점이 없어 자연히 걸러진다. 파일이 없거나 깨졌으면 빈 값.
"""
path = pipe_points_file(project_root)
if not path.is_file():
return {}
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("B06 배수관 세트: 관 지점 파일을 읽지 못했습니다 (%s)", path)
return {}
sets: dict[float, dict[str, Any]] = {}
for point in parse_pipe_points(document.get("points")):
# BOX암거·물넘이포장·세월교는 그림이 다르다 — 이번 범위는 배관뿐이다.
if point.facility != PIPE_FACILITY_PIPE:
continue
sets[round(float(point.chainage_m), 2)] = _culvert_set(point.options)
return sets
def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]) -> int:
"""배관이 놓인 측점의 횡단 dict에 `culvert` 키를 얹는다. 얹은 개수를 돌려준다."""
sets = load_culvert_sets(project_root)
if not sets:
return 0
attached = 0
for section in cross_sections:
chainage = _number(section.get("chainage_m"), None)
if chainage is None:
continue
for pipe_chainage, spec in sets.items():
if abs(chainage - pipe_chainage) <= _CHAINAGE_TOLERANCE_M:
section["culvert"] = spec
attached += 1
break
return attached
+5
View File
@@ -20,6 +20,7 @@ from B05_Profile.B05_Profile_Engine_Sections import (
run_section_generation,
)
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
from B06_Section.B06_Section_Engine_Culvert import attach_culvert_sets
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Repository import (
count_cross_sections,
@@ -227,6 +228,8 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
isinstance(section, dict) for section in cross_sections
):
raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.")
# 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다.
attach_culvert_sets(root, cross_sections)
return {"longitudinal": longitudinal, "cross_sections": cross_sections}
@@ -431,6 +434,8 @@ async def regenerate_sections(
await connection.rollback()
raise
result = sections["result"]
# 재생성 응답도 상세 조회와 같은 배수관 세트 정보를 실어야 화면이 어긋나지 않는다.
attach_culvert_sets(project_root, result["cross_sections"])
return SectionDetailResponse(
longitudinal=result["longitudinal"], cross_sections=result["cross_sections"]
)
+261
View File
@@ -0,0 +1,261 @@
/* =============================================================================
* 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();
}
@@ -0,0 +1,729 @@
/* =============================================================================
* 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,
};
}
+97 -11
View File
@@ -435,6 +435,10 @@ export function buildDesignControls(
/**
* SVG에 . (
* ) , .
*
* `trim` offset
* (2026-08-20 ).
* .
*/
export function appendCrossDesignOverlay(
svg: SVGElement,
@@ -442,6 +446,7 @@ export function appendCrossDesignOverlay(
x: (offset: number) => number,
toDisplayY: (elevation: number) => number,
groundSamples: SectionSample[],
trim?: { minOffset: number; maxOffset: number },
): void {
const line = design.design_line;
if (!line || line.length < 2) return;
@@ -475,19 +480,100 @@ export function appendCrossDesignOverlay(
return Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX;
});
// 세그먼트별로 그린다: 양 끝이 모두 겹치면 점선(overlap), 아니면 실선.
// 사면 끝(계획선이 원지반과 처음 만나는 지점) **바깥**은 계획선을 그리지 않는다 —
// 그 너머는 손대지 않는 원지반이라 지반선이 이미 표현한다(2026-08-20 사용자 지적:
// 12+0.0 좌측 성토부에서 접점 이후 계획선이 계속 그려짐).
// 탐색은 **노견 바깥**에서 시작한다 — 노면·측구·노견을 지나는 계획선은 지반과
// 우연히 겹쳐도 지워선 안 되고, 노견 이후 사면이 지반과 만나는 2차 접점만 종료점이다
// (2026-08-20 사용자 ①).
const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m);
const edgesForTrim = design.road_edges;
const leftEdge = edgesForTrim
? Math.max(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m)
: 0;
const rightEdge = edgesForTrim
? Math.min(edgesForTrim.left.offset_m, edgesForTrim.right.offset_m)
: 0;
// 측구는 노견 **바깥**에 붙는다(road_edges 밖). 측구가 있는 쪽은 측구 바깥 끝까지
// 보호하고 그 다음부터 접점을 찾는다 — 노견에서 바로 찾으면 측구가 통째로 잘린다
// (2026-08-20 사용자: "측구 이후 성토부가 존재하는 경사로의 1차 교차점까지 보호").
const ditchSpec = design.ditch;
const ditchWidth =
design.ditch_enabled === false || !ditchSpec || ditchSpec.type === "none"
? 0
: ditchSpec.type === "standard"
? ditchSpec.top_width_m
: ditchSpec.width_m;
// 좌표 규약: +offset = 좌측. ditch_side "left" = 큰 offset 쪽.
const protectMax = leftEdge + (design.ditch_side === "left" ? ditchWidth : 0);
const protectMin = rightEdge - (design.ditch_side === "right" ? ditchWidth : 0);
const meetOffset = (startOffset: number, step: number): number | null => {
const indices =
step > 0 ? sorted.map((_, i) => i) : sorted.map((_, i) => sorted.length - 1 - i);
for (const i of indices) {
const point = sorted[i];
// 노견 안쪽(도로 구간)은 건너뛴다.
if (step > 0 ? point.offset_m <= startOffset : point.offset_m >= startOffset) continue;
const g = groundAt(point.offset_m);
if (g === null) continue;
if (Math.abs(toDisplayY(point.elevation_m) - toDisplayY(g)) <= OVERLAP_PX)
return point.offset_m;
}
return null;
};
const meetLeft = meetOffset(protectMin, -1); // 작은 offset 쪽(측구까지 지난 뒤)
const meetRight = meetOffset(protectMax, 1); // 큰 offset 쪽(측구까지 지난 뒤)
// 트림 범위로 구간 하나를 자른다. 완전히 밖이면 null, 걸치면 경계에서 보간해 끊는다.
const clipSegment = (
a: { offset_m: number; elevation_m: number },
b: { offset_m: number; elevation_m: number },
):
| [{ offset_m: number; elevation_m: number }, { offset_m: number; elevation_m: number }]
| null => {
// 배수관 세트 트림 + 사면 끝 접점 트림을 합친 유효 범위.
const minOffset = Math.max(trim?.minOffset ?? -Infinity, meetLeft ?? -Infinity);
const maxOffset = Math.min(trim?.maxOffset ?? Infinity, meetRight ?? Infinity);
if (!Number.isFinite(minOffset) && !Number.isFinite(maxOffset)) return [a, b];
const effective = { minOffset, maxOffset };
const lo = Math.min(a.offset_m, b.offset_m);
const hi = Math.max(a.offset_m, b.offset_m);
if (hi <= effective.minOffset || lo >= effective.maxOffset) return null;
const at = (offset: number) => {
const span = b.offset_m - a.offset_m;
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 };
};
const clamp = (p: { offset_m: number; elevation_m: number }) =>
p.offset_m < effective.minOffset
? at(effective.minOffset)
: p.offset_m > effective.maxOffset
? at(effective.maxOffset)
: p;
return [clamp(a), clamp(b)];
};
// 세그먼트별로 그린다. 지면선과 겹치는 구간(= 계획선이 원지반과 차이가 없는 구간)은
// **그리지 않는다** — 지반선이 이미 같은 자리를 표현하므로 정보가 없다
// (2026-08-20 사용자 지시: 보라색 점선 삭제).
// 단 **노면·노견·측구부터 1차 접점까지는 언제나 그린다** — 계획고가 지반과 우연히
// 몇 ㎝ 차이면 노면 계획선이 통째로 끊겨 보이고(8·10·10+0.9), 노견 끝 성토고가
// 4㎝ 정도면 접점까지의 짧은 사면 조각도 사라진다(9+0.0 — 2026-08-20 사용자 지적).
// 접점을 못 찾은 쪽은 보호 구간을 노견·측구까지로 둔다.
const keepMin = meetLeft ?? protectMin;
const keepMax = meetRight ?? protectMax;
const isProtected = (a: { offset_m: number }, b: { offset_m: number }): boolean =>
Math.max(a.offset_m, b.offset_m) > keepMin && Math.min(a.offset_m, b.offset_m) < keepMax;
for (let i = 1; i < line.length; i += 1) {
if (overlap[i - 1] && overlap[i] && !isProtected(line[i - 1], line[i])) continue;
const clipped = clipSegment(line[i - 1], line[i]);
if (!clipped) continue;
const seg = document.createElementNS(SVG_NS, "line");
seg.setAttribute("x1", String(x(line[i - 1].offset_m)));
seg.setAttribute("y1", String(toDisplayY(line[i - 1].elevation_m)));
seg.setAttribute("x2", String(x(line[i].offset_m)));
seg.setAttribute("y2", String(toDisplayY(line[i].elevation_m)));
seg.setAttribute(
"class",
overlap[i - 1] && overlap[i]
? "b06-chart__design-cross b06-chart__design-cross--overlap"
: "b06-chart__design-cross",
);
seg.setAttribute("x1", String(x(clipped[0].offset_m)));
seg.setAttribute("y1", String(toDisplayY(clipped[0].elevation_m)));
seg.setAttribute("x2", String(x(clipped[1].offset_m)));
seg.setAttribute("y2", String(toDisplayY(clipped[1].elevation_m)));
seg.setAttribute("class", "b06-chart__design-cross");
svg.append(seg);
}
+13 -1
View File
@@ -23,6 +23,7 @@ import {
sectionModeLabel,
type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design";
import { appendCulvertOverlay, computeCulvertLayout } from "./B06_Section_UI_Cross_Culvert";
import {
CROSS_HEIGHT,
CROSS_PAD,
@@ -525,9 +526,18 @@ export function createCrossSectionCard(
toDisplayY,
toggleArea,
);
// 배수관 세트 기하를 먼저 계산한다 — 설계선이 기슭막이 밖 성토 경사를 끊는 데 쓴다.
const culvertLayout = computeCulvertLayout(section, sourceSamples);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
appendCrossDesignOverlay(plotLayer, section.design, x, toDisplayY, sourceSamples);
appendCrossDesignOverlay(
plotLayer,
section.design,
x,
toDisplayY,
sourceSamples,
culvertLayout?.designTrim ?? undefined,
);
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
if (rockBoundary && section.design.geometry_preset === "rock") {
appendRockBoundaryOverlay(
@@ -538,6 +548,8 @@ export function createCrossSectionCard(
toDisplayY,
);
}
// 배수관 측점 세트(배관·기슭막이·보호공) — 기존 도형 위에 추가만 한다(2026-08-19).
if (culvertLayout) appendCulvertOverlay(plotLayer, culvertLayout, x, toDisplayY);
}
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
@@ -162,6 +162,75 @@
stroke-width: 1;
}
/* 배수관 세트(2026-08-19): 배관·기슭막이·돌붙임 — 분리 도형 3종 + 집수정 라벨 */
/* 관은 실무 도면처럼 점선 윤곽 2줄 느낌 — 채움은 식별용으로만 아주 옅게. */
.b06-chart__culvert-pipe {
fill: color-mix(in srgb, #4f8ef7 10%, transparent); /* B05 배수관 레지스트리 색 계열 */
stroke: #4f8ef7;
stroke-width: 1.4;
stroke-dasharray: 6 3;
stroke-linejoin: round;
}
/* 관 외경선 — 관벽 두께만큼 바깥. 채움 없이 윤곽만(내경선과 두 줄로 보인다). */
.b06-chart__culvert-pipe--outer {
fill: none;
stroke-dasharray: none;
stroke-width: 1.1;
}
.b06-chart__culvert-revet {
fill: color-mix(in srgb, #8a7a5c 35%, transparent); /* 돌쌓기 석재 계열 */
stroke: #6f6248;
stroke-width: 1.2;
stroke-linejoin: round;
}
.b06-chart__culvert-apron {
fill: color-mix(in srgb, var(--color-text-secondary) 22%, transparent);
stroke: var(--color-text-secondary);
stroke-width: 1;
stroke-dasharray: 3 2; /* 지반 아래 매설부 — 점선으로 구분 */
}
.b06-chart__culvert-label {
font-size: 9px;
font-weight: var(--font-weight-medium);
fill: var(--color-text-secondary);
}
/* 유입 보호공 공간 확보 절토선 — 말단에서 표준단면 절토경사로 지반까지 */
.b06-chart__culvert-cut {
stroke: var(--color-warning);
stroke-width: 1.4;
stroke-dasharray: 5 3;
}
/* 기슭막이 돌 해칭 — 기운 띠 안에 한 줄로 쌓인 큰 돌(실무 도면 형상) */
.b06-chart__culvert-stone {
fill: none;
stroke: #6f6248;
stroke-width: 1;
opacity: 0.85;
}
/* 유입 집수정(ㄴ형) — 절토측 또는 원지반 30% 폐색 시 자동 반영되는 박스 단면 */
.b06-chart__culvert-basin {
fill: color-mix(in srgb, #8a7a5c 32%, transparent);
stroke: #6f6248;
stroke-width: 1.2;
stroke-linejoin: round;
}
/* 유출부 돌붙임 — 성토사면을 따라 원지반까지 덮는 돌붙임 띠(실무 도면의 돌 연쇄) */
.b06-chart__culvert-pitching {
fill: color-mix(in srgb, #3fa34d 18%, transparent);
stroke: #3fa34d;
stroke-width: 1.2;
stroke-dasharray: 4 2;
stroke-linejoin: round;
}
/* 암 경계선 상/하/리셋 제어 (B05 측점 선 제어 ▲/▼/↺ 버튼 패턴 재활용) */
.b06-design__rockb-btn {
width: 22px;