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:
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user