diff --git a/B05_Profile/B05_Profile_UI_Profile_Render.ts b/B05_Profile/B05_Profile_UI_Profile_Render.ts index 5ee08c3e..2c6fc399 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Render.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Render.ts @@ -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), diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 4f2a1d73..ed498fc1 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -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 { diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py new file mode 100644 index 00000000..24d75834 --- /dev/null +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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 diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index 48310ed9..247e300a 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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"] ) diff --git a/B06_Section/B06_Section_UI_Cross_Culvert.ts b/B06_Section/B06_Section_UI_Cross_Culvert.ts new file mode 100644 index 00000000..45324a1f --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert.ts @@ -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(); +} diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts new file mode 100644 index 00000000..1de1347a --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Geom.ts @@ -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, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index 42282a66..b2a5f89d 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -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); } diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 2944f9d9..cb5bedd7 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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) => { diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index bfde1cb6..e156ea3c 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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;