diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index c5786d16..263d039e 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -22,7 +22,10 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import ( generate_sections, ) from B05_Profile.B05_Profile_Structures_Repository import load_structures -from B05_Profile.B05_Profile_Structures_Schema import structure_type_map +from B05_Profile.B05_Profile_Structures_Schema import ( + is_station_planting_type, + structure_type_map, +) from common_util.common_util_drainage_pipes import ( PIPE_FACILITY_FORD_PAVEMENT, PIPE_FACILITY_PIPE, @@ -172,13 +175,26 @@ def resolve_extra_stations( continue label = definition.name derived.append((float(pipe.chainage_m), label)) - # A군 수동 구조물(노출형 횡단수로·개거)도 같은 자리에 측점이 필요하다. 관 정본이 - # 관리하는 타입(managed_by)은 structures.json에 저장되지 않아 중복되지 않는다. + # 구조물 정본에서 측점이 필요한 것들. 관 정본이 관리하는 타입(managed_by)은 + # structures.json에 저장되지 않아 중복되지 않는다. + # · 점형(A군 노출형 횡단수로·개거): 기준 측점 한 곳. + # · 구간형(D군 기슭막이): **시작·기준·종료** — 길이가 길수록 횡단도가 여러 장 + # 나와야 한다(2026-08-28 사용자). 격자와 같은 정수 미터면 generate_sections가 + # 격자로 스냅해 파일이 겹치지 않는다. try: for structure in load_structures(str(project_root))[1]: definition = types.get(structure.type_id) - if definition and definition.group == "A" and not definition.managed_by: - derived.append((float(structure.anchor_m()), definition.name)) + if definition is None or definition.managed_by: + continue + if not is_station_planting_type(definition): + continue + if definition.placement == "interval": + marks = (structure.start_m, structure.chainage_m, structure.end_m) + else: + marks = (structure.anchor_m(),) + for mark in marks: + if mark is not None: + derived.append((float(mark), definition.name)) except Exception: logger.exception("B05: 구조물 정본을 읽지 못했습니다 (관 유래 측점만 씁니다)") return tuple(sorted(derived)) diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 5a150485..5689b759 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -983,6 +983,15 @@ }, "drawing_views": ["profile", "cross_section", "quantity"], "options": [ + { + "key": "side", + "label": "설치 측", + "input": "select", + "choices": ["좌", "우"], + "default": "좌", + "required": false, + "phase": "b05" + }, { "key": "form", "label": "형태", @@ -1322,21 +1331,21 @@ "label": "길이", "input": "number", "unit": "m", - "default": 0 + "default": 10 }, { "key": "before_m", "label": "기준측점 전", "input": "number", "unit": "m", - "default": 0 + "default": 5 }, { "key": "after_m", "label": "기준측점 후", "input": "number", "unit": "m", - "default": 0 + "default": 5 }, { "key": "thickness_cm", diff --git a/B05_Profile/B05_Profile_Structures_Migration.py b/B05_Profile/B05_Profile_Structures_Migration.py index 25b1f8da..ebca466b 100644 --- a/B05_Profile/B05_Profile_Structures_Migration.py +++ b/B05_Profile/B05_Profile_Structures_Migration.py @@ -17,7 +17,7 @@ from typing import Any, Iterable from B05_Profile.B05_Profile_Structures_Schema import ( StructureInstance, - load_structure_types, + station_planting_labels, structure_type_map, ) @@ -38,11 +38,10 @@ _ESCAPE_LABEL = re.compile(r"^대피로(?:\s+([\d.]+)\s*m)?", re.UNICODE) def _managed_elsewhere_labels() -> set[str]: - """A군(계곡 통과 시설·횡단배수) 표시 이름 — 서버가 종단 정본에 직접 심는 라벨이라 - 구조물로 재이관하면 안 된다(정본 이중화 → "기타" 고스트). 레지스트리에서 뽑아 목록이 - 바뀌어도 따라간다(load_structure_types는 lru_cache). + """서버가 종단 정본에 직접 심는 라벨 — 구조물로 재이관하면 안 된다(정본 이중화 → + "기타" 고스트). 규칙은 측점 생성과 **같은 자리**에서 온다(station_planting_labels). """ - return {item.name for item in load_structure_types() if item.group == "A"} + return station_planting_labels() def _is_managed_elsewhere(entry: dict[str, Any]) -> bool: diff --git a/B05_Profile/B05_Profile_Structures_Schema.py b/B05_Profile/B05_Profile_Structures_Schema.py index f387398f..fc59be63 100644 --- a/B05_Profile/B05_Profile_Structures_Schema.py +++ b/B05_Profile/B05_Profile_Structures_Schema.py @@ -78,6 +78,26 @@ def structure_type_map() -> dict[str, StructureType]: return {item.type_id: item for item in load_structure_types()} +def is_station_planting_type(item: StructureType) -> bool: + """서버가 종단 정본에 **측점으로 심는** 타입인가 (단일 규칙, 2026-08-28). + + - A군(횡단배수·계곡 통과 시설): 그 자리에 횡단도가 필요하다. + - D군 구간형(기슭막이 등): 구간 시작·기준·종료에 횡단도가 필요하다 — 길이가 길면 + 그만큼 여러 장이 나온다(사용자 확정). + + 측점 생성(`resolve_extra_stations`)과 구조물 재이관 차단 + (`B05_Profile_Structures_Migration`)이 같은 규칙을 봐야 "기타" 고스트가 안 생긴다. + """ + if item.group == "A": + return True + return item.group == "D" and item.placement == "interval" + + +def station_planting_labels() -> set[str]: + """서버가 심는 측점 라벨 집합 — 구조물로 되옮기면 정본이 이중화된다.""" + return {item.name for item in load_structure_types() if is_station_planting_type(item)} + + def registry_schema_version() -> int: with open(_REGISTRY_PATH, encoding="utf-8") as handle: return int(json.load(handle).get("schema_version", 1)) diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index a4b0c292..8a2f9d5b 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -195,7 +195,11 @@ function fnv1a(text: string): string { // (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다. // 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을 // 가르고 **닫힌 고리만** 담는다. -const BUILD_VERSION = 84; +// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견 +// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자). +// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤 +// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자). +const BUILD_VERSION = 86; /** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */ export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string { diff --git a/B05_Profile/B05_Profile_UI_Corridor_Station.ts b/B05_Profile/B05_Profile_UI_Corridor_Station.ts index 53f88377..88f575d3 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Station.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Station.ts @@ -9,6 +9,7 @@ * (2026-08-23 사용자 지시)의 핵심. * ========================================================================== */ +import { fordDeckElevationAt } from "../B06_Section/B06_Section_UI_Cross_Ford_Pavement"; import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; import { structureSilhouettes } from "./B05_Profile_UI_Corridor_Station_Structure"; @@ -237,9 +238,24 @@ export function classifyStation(section: CrossSection): StationPieces | null { const design = section.design; const line = design?.design_line; if (!design || !line || line.length < 2) return null; - const sorted = [...line].sort((a, b) => a.offset_m - b.offset_m); const roadL = design.road_edges.left.offset_m; const roadR = design.road_edges.right.offset_m; + // 물넘이포장 — 노면(노견 포함)이 파인다. 횡단도와 **같은 산식**으로 표고를 내려 + // 3D도 같은 자리에 같은 깊이로 팬다(2026-08-28 사용자 지시). 노면 밖 비탈은 그대로라 + // 노견 끝에 파임 벽(수직 단차)이 선다 — 횡단도 그림과 같은 표현이다. + const ford = section.ford_pavement; + const carved = + ford && ford.depth_m + ? line.map((point) => + point.offset_m <= roadL + 1e-9 && point.offset_m >= roadR - 1e-9 + ? { + ...point, + elevation_m: fordDeckElevationAt(design, ford, point.offset_m, point.elevation_m), + } + : point, + ) + : line; + const sorted = [...carved].sort((a, b) => a.offset_m - b.offset_m); const cwL = design.carriageway_edges?.left.offset_m ?? roadL; const cwR = design.carriageway_edges?.right.offset_m ?? roadR; diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 3126ac41..a1f05911 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -38,6 +38,7 @@ import type { SectionPolygon } from "./B05_Profile_UI_Corridor_Structures_Box"; import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Box"; import type { BoxAdjust, BoxLayout } from "../B06_Section/B06_Section_UI_Cross_Box"; import { buildWingSolids } from "./B05_Profile_UI_Corridor_Structures_Wing"; +import { computeRevetmentLayout } from "../B06_Section/B06_Section_UI_Cross_Revetment"; /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */ export interface StructureFrame { @@ -274,7 +275,9 @@ export function buildCorridorStructures( for (const section of crossSections) { const layout = culvertLayoutOf(section); const fordLayout = fordLayoutOf(section); - if (!layout && !fordLayout && !section.box) continue; + // 독립 기슭막이 — 횡단 카드와 **같은 폴리곤**을 그대로 스윕한다(2026-08-28 사용자). + const revetLayout = computeRevetmentLayout(section); + if (!layout && !fordLayout && !section.box && !revetLayout) continue; const chainage = section.chainage_m; const stationFrame: StructureFrame = { cx: section.center_x, @@ -326,6 +329,10 @@ export function buildCorridorStructures( }); }; + if (revetLayout) { + pushSwept("revet", revetLayout.polygon, revetLayout.span.beforeM, revetLayout.span.afterM); + } + /** 링 누가거리 목록으로 프레임을 만든다(보어처럼 촘촘한 간격이 필요할 때). */ const ringsAt = (chainages: number[]): StructureFrame[] => chainages.map((at) => { @@ -571,9 +578,29 @@ export function buildCorridorStructures( /** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ export function structureHashParts(section: CrossSection): Array { + // 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 — + // 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28). + const extras: Array = []; + const fordPave = section.ford_pavement; + if (fordPave) { + extras.push("fp", fordPave.span_m, fordPave.depth_m ?? "", fordPave.slope_pct ?? ""); + } + const ownRevet = section.revetment; + if (ownRevet) { + extras.push( + "rv", + ownRevet.start_m, + ownRevet.end_m, + ownRevet.anchor_m, + ownRevet.height_m ?? "", + ownRevet.side ?? "", + ownRevet.form ?? "", + ); + } const box = section.box; if (box) { return [ + ...extras, "bx", box.inner_width_m, box.inner_height_m, @@ -598,6 +625,7 @@ export function structureHashParts(section: CrossSection): Array [ spec.structure, @@ -626,6 +654,7 @@ export function structureHashParts(section: CrossSection): Array ({ + kind: solid.kind, + at: solid.chainage_m, + rings: solid.rings?.length ?? 0, + points: solid.polygon?.length ?? 0, + })), // 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다. raw: { ribbons: build.ribbons, diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 581d7c01..615828de 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -69,7 +69,8 @@ export type { /** 물넘이포장 제원 — 정의처는 렌더 모듈이다(사본 금지, 타입 전용 import라 순환 없음). */ import type { FordPavementSpec } from "./B06_Section_UI_Cross_Ford_Pavement"; -export type { FordPavementSpec }; +import type { RevetmentSpec } from "./B06_Section_UI_Cross_Revetment"; +export type { FordPavementSpec, RevetmentSpec }; export interface SectionContextResponse { project_id: string; @@ -282,6 +283,8 @@ export interface CrossSection extends SectionStation { box?: BoxSet; /** 물넘이포장이 파는 노면 제원(있을 때만). 월류 폭 안의 측점 전부에 붙는다. */ ford_pavement?: FordPavementSpec; + /** 독립 기슭막이 제원(있을 때만). 구조물 정본 D군 구간 안의 측점 전부에 붙는다. */ + revetment?: RevetmentSpec; } export interface SectionDetailResponse { diff --git a/B06_Section/B06_Section_Engine_Revetment.py b/B06_Section/B06_Section_Engine_Revetment.py new file mode 100644 index 00000000..856631df --- /dev/null +++ b/B06_Section/B06_Section_Engine_Revetment.py @@ -0,0 +1,78 @@ +"""독립 기슭막이(구조물 정본 D군) 제원을 횡단 측점에 얹는다. + +배관 유입·유출에 딸린 기슭막이는 관 정본이 관리하지만(`B06_Section_Engine_Culvert`), +배관과 무관한 **독립 기슭막이**는 구조물 정본(`structures.json`)이 정본이다. 여기서는 +구간(시작~종료) 안의 측점에 형태·높이·설치 측을 붙이기만 한다 — 치수 결정·도형은 +화면(`B06_Section_UI_Cross_Revetment`)과 3D가 같은 산식으로 그린다(2026-08-28 사용자 확정). + +측점 자체는 `B05_Profile_Engine_Sections.resolve_extra_stations`가 시작·기준·종료에 심는다. +""" + +import logging +from pathlib import Path +from typing import Any + +from B05_Profile.B05_Profile_Structures_Repository import load_structures +from B05_Profile.B05_Profile_Structures_Schema import structure_type_map + +logger = logging.getLogger(__name__) + +# 구간 경계 측점을 구간 안으로 볼 허용 오차(m) — 정본이 누가거리를 0.01m로 끊어 쓴다. +_EDGE_TOLERANCE_M = 0.02 + + +def load_revetments(project_root: Path) -> list[dict[str, Any]]: + """구조물 정본에서 독립 기슭막이(D군 구간형) 목록을 읽는다. 실패하면 빈 목록.""" + try: + types = structure_type_map() + found: list[dict[str, Any]] = [] + for structure in load_structures(str(project_root))[1]: + definition = types.get(structure.type_id) + if definition is None or definition.group != "D" or definition.placement != "interval": + continue + start, end = structure.start_m, structure.end_m + if start is None or end is None: + continue + options = structure.options or {} + found.append( + { + "structure_id": structure.structure_id, + "type_id": structure.type_id, + "name": definition.name, + "start_m": float(min(start, end)), + "end_m": float(max(start, end)), + "anchor_m": float(structure.anchor_m()), + "form": options.get("form"), + "height_m": options.get("height_m"), + "side": options.get("side"), + } + ) + return found + except Exception: # noqa: BLE001 — 정본을 못 읽어도 횡단 조회는 이어 간다 + logger.exception("B06 독립 기슭막이 정본을 읽지 못했습니다 (없는 것으로 본다)") + return [] + + +def attach_revetments(project_root: Path, cross_sections: list[dict[str, Any]]) -> int: + """구간 안 측점의 횡단 dict에 `revetment` 키를 얹는다. 얹은 개수를 돌려준다. + + 한 측점에 여러 개가 겹치면 **먼저 시작한 것**을 쓴다 — 겹침 정리는 사용자 몫이다. + """ + revetments = load_revetments(project_root) + if not revetments: + return 0 + attached = 0 + for section in cross_sections: + chainage = section.get("chainage_m") + if not isinstance(chainage, (int, float)): + continue + for spec in revetments: + if ( + spec["start_m"] - _EDGE_TOLERANCE_M + <= float(chainage) + <= spec["end_m"] + _EDGE_TOLERANCE_M + ): + section["revetment"] = spec + attached += 1 + break + return attached diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index d7d7e4f4..063980b2 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -21,6 +21,7 @@ from B05_Profile.B05_Profile_Engine_Sections import ( ) 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_Revetment import attach_revetments from B06_Section.B06_Section_Engine_Design import compute_cross_design from B06_Section.B06_Section_Repository import ( count_cross_sections, @@ -43,6 +44,7 @@ from B06_Section.B06_Section_Router_Design import ( attach_default_designs as _attach_default_designs, compute_default_designs as _compute_default_designs, enforce_pavement_ranges as _enforce_pavement_ranges, + stored_standard_cross_section as _stored_standard_cross_section, pavement_ranges as _pavement_ranges, paved_at as _paved_at, ) @@ -252,6 +254,8 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic raise ValueError("종횡단 상세 파일 형식이 올바르지 않습니다.") # 배수관 측점에 세트(배관·기슭막이·보호공) 제원을 얹는다 — 횡단 카드가 그림을 그린다. attach_culvert_sets(root, cross_sections) + # 배관과 무관한 **독립 기슭막이**(구조물 정본 D군)는 구간 안 측점 전부에 얹는다. + attach_revetments(root, cross_sections) return {"longitudinal": longitudinal, "cross_sections": cross_sections} @@ -307,11 +311,14 @@ async def get_section_detail( section["design"] = record["design"] break # 포장 구간·물넘이 범위는 저장분이 비포장이어도 포장으로 맞춘다(2026-08-28). + # 표준횡단면은 확정 때 저장해 둔 사용자 값을 쓴다 — 없으면 config 기본값. + standard = _stored_standard_cross_section(longitudinal) await asyncio.to_thread( _enforce_pavement_ranges, detail["longitudinal"], detail["cross_sections"], project_root, + standard, ) # 지정값이 없는 측점은 기본값(토사/좌절토)으로 즉석 계산해 프리뷰로 채운다. # (미저장 프리뷰: 실제 저장은 사용자가 카드를 조작하거나 확정할 때 이뤄진다.) @@ -320,6 +327,7 @@ async def get_section_detail( detail["longitudinal"], detail["cross_sections"], project_root, + standard, ) return SectionDetailResponse(**detail, balloon_offsets=_read_balloon_offsets(longitudinal)) except FileNotFoundError as exc: @@ -469,6 +477,7 @@ async def regenerate_sections( result = sections["result"] # 재생성 응답도 상세 조회와 같은 배수관 세트 정보를 실어야 화면이 어긋나지 않는다. attach_culvert_sets(project_root, result["cross_sections"]) + attach_revetments(project_root, result["cross_sections"]) return SectionDetailResponse( longitudinal=result["longitudinal"], cross_sections=result["cross_sections"] ) diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index 2ef4d800..4509f63e 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -91,8 +91,23 @@ _USER_TOUCHED_KEYS = ( ) +def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> dict[str, Any] | None: + """확정 때 저장해 둔 표준횡단면 설정값(없으면 None = config 기본값). + + 사용자가 「표준 횡단면 설정」에서 고친 값은 확정 시 종단 정본 options에 실린다. + 포장 강제 재계산도 같은 값을 써야 카드가 패널과 어긋나지 않는다(2026-08-28). + """ + options = (longitudinal_row or {}).get("data") or {} + options = options.get("options") if isinstance(options, dict) else None + standard = options.get("standard_cross_section") if isinstance(options, dict) else None + return standard if isinstance(standard, dict) else None + + def enforce_pavement_ranges( - longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], project_root: Path + longitudinal: dict[str, Any], + cross_sections: list[dict[str, Any]], + project_root: Path, + standard: dict[str, Any] | None = None, ) -> int: """포장 구간·물넘이 범위 안 측점을 포장으로 맞춘다 — 저장분이 비포장이어도 그렇다. @@ -120,6 +135,7 @@ def enforce_pavement_ranges( ditch_side=design.get("ditch_side"), ditch_type=str(design.get("ditch_type") or "standard"), paved=True, + standard=standard, rock_boundary_offset_m=design.get( "rock_boundary_offset_m", STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M ), @@ -173,6 +189,7 @@ def attach_default_designs( longitudinal: dict[str, Any], cross_sections: list[dict[str, Any]], project_root: Path | None = None, + standard: dict[str, Any] | None = None, ) -> None: modes = default_section_modes(longitudinal) pavement = pavement_suggestions(longitudinal) @@ -190,6 +207,7 @@ def attach_default_designs( section_mode=modes.get(round(chainage, 3), "left_cut"), # 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28). paved=paved_at(chainage, paved_ranges), + standard=standard, rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M, ) design.update(status="provisional", pavement_suggested=suggested) diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts index 865f3036..3f4439ac 100644 --- a/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts +++ b/B06_Section/B06_Section_UI_Cross_Ford_Pavement.ts @@ -40,11 +40,24 @@ function inflowSign(design: CrossDesign, offsetM: number): number { return towardLeft === inflowLeft ? 1 : -1; } -function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number { +/** + * 파인 노면의 표고(m). 횡단도와 3D 코리도가 **같은 식**을 써야 두 그림이 어긋나지 않는다. + * 중심에서 깊이만큼 내리고, 유입측으로 갈수록 경사만큼 올린다. + */ +export function fordDeckElevationAt( + design: CrossDesign, + spec: FordPavementSpec, + offsetM: number, + fallbackElevationM = 0, +): number { const depth = spec.depth_m ?? 0; const slope = (spec.slope_pct ?? design.cross_slope_pct) / 100; - const center = design.design_elevation_m ?? edge.elevation_m; - return center - depth + slope * Math.abs(edge.offset_m) * inflowSign(design, edge.offset_m); + const center = design.design_elevation_m ?? fallbackElevationM; + return center - depth + slope * Math.abs(offsetM) * inflowSign(design, offsetM); +} + +function bottomAt(design: CrossDesign, spec: FordPavementSpec, edge: Edge): number { + return fordDeckElevationAt(design, spec, edge.offset_m, edge.elevation_m); } function line(points: string[], className: string): SVGPolylineElement { diff --git a/B06_Section/B06_Section_UI_Cross_Revetment.ts b/B06_Section/B06_Section_UI_Cross_Revetment.ts new file mode 100644 index 00000000..ab1a35f5 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Revetment.ts @@ -0,0 +1,155 @@ +/* ============================================================================= + * B06_Section_UI_Cross_Revetment.ts + * 독립 기슭막이(배관과 무관) — 횡단 단면 기하 + 그리기(2026-08-28 사용자 확정). + * + * 자리: **성토면 끝(설계선이 원지반과 만나는 지점)**. 벽 상단이 그 지점 표고이고 + * 아래로 높이 + 근입 0.5m만큼 내려간다. 전면(사면 반대쪽)은 1:0.3으로 기운다 + * (돌쌓기 전면, 교본 7-3). 설치 측은 **사용자가 좌/우로 지정**한다 — 자동 판정 없음. + * + * 3D 코리도도 이 폴리곤을 그대로 스윕한다(`B05_Profile_UI_Corridor_Structures`) — + * 단면 기하를 두 벌 만들지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./B06_Section_Api_Fetch"; +import { + REVET_EMBED_DEPTH_M, + REVET_LEAN_RATIO, + REVET_THICKNESS_M, +} from "./B06_Section_UI_Cross_Culvert_Const"; + +const SVG_NS = "http://www.w3.org/2000/svg"; + +/** 서버가 얹어 주는 독립 기슭막이 제원(`section.revetment`, 구조물 정본 D군). */ +export interface RevetmentSpec { + structure_id: string | null; + type_id: string; + name: string; + start_m: number; + end_m: number; + anchor_m: number; + form?: string | null; + height_m?: number | null; + /** 사용자가 고른 설치 측 — "좌" | "우". */ + side?: string | null; +} + +export interface RevetPoint { + offset: number; + elevation: number; +} + +export interface RevetmentLayout { + side: "left" | "right"; + /** 벽 상단 자리 = 성토면 끝(설계선-지반 교차점). */ + top: RevetPoint; + /** 단면 폴리곤(도로측 수직 배면 → 상단 → 기운 전면 → 바닥). */ + polygon: RevetPoint[]; + /** 기준 측점 전/후 점유 길이(m) — 3D 스윕 범위. */ + span: { beforeM: number; afterM: number }; +} + +/** 지반 표고 보간 — 샘플 사이는 선형. 유효 표본이 없으면 null. */ +function groundElevationAt(section: CrossSection, offsetM: number): number | null { + const points = section.samples + .filter( + (sample) => + sample.valid !== false && + typeof sample.offset_m === "number" && + typeof sample.elevation_m === "number", + ) + .map((sample) => ({ offset: sample.offset_m as number, elevation: sample.elevation_m as number })) + .sort((a, b) => a.offset - b.offset); + const first = points[0]; + const last = points[points.length - 1]; + if (!first || !last || points.length < 2) return null; + if (offsetM <= first.offset) return first.elevation; + if (offsetM >= last.offset) return last.elevation; + for (let i = 1; i < points.length; i += 1) { + const a = points[i - 1]; + const b = points[i]; + if (!a || !b) continue; + if (offsetM <= b.offset) { + const t = (offsetM - a.offset) / (b.offset - a.offset || 1); + return a.elevation + (b.elevation - a.elevation) * t; + } + } + return null; +} + +/** + * 성토면 끝 — 노견 바깥으로 나가며 설계선이 원지반과 만나는 첫 지점. + * 만나지 않으면(설계선이 지반 위로만 지나면) 설계선 끝점을 쓴다. + */ +function fillToeAt(section: CrossSection, side: "left" | "right"): RevetPoint | null { + const design = section.design; + const line = design?.design_line; + if (!design || !line || line.length < 2) return null; + const outward = side === "left" ? 1 : -1; + const edge = design.road_edges[side].offset_m; + const beyond = line + .filter((point) => (point.offset_m - edge) * outward > 1e-9) + .sort((a, b) => (a.offset_m - b.offset_m) * outward); + let previous: { offset: number; gap: number } | null = null; + for (const point of beyond) { + const ground = groundElevationAt(section, point.offset_m); + if (ground === null) continue; + const gap = point.elevation_m - ground; + if (previous && previous.gap > 0 && gap <= 0) { + const t = previous.gap / (previous.gap - gap || 1); + const offset = previous.offset + (point.offset_m - previous.offset) * t; + const elevation = groundElevationAt(section, offset); + if (elevation !== null) return { offset, elevation }; + } + previous = { offset: point.offset_m, gap }; + } + const tail = beyond[beyond.length - 1]; + if (!tail) return null; + return { offset: tail.offset_m, elevation: tail.elevation_m }; +} + +/** 독립 기슭막이 단면. 제원·높이가 없거나 성토면 끝을 못 찾으면 null. */ +export function computeRevetmentLayout(section: CrossSection): RevetmentLayout | null { + const spec = section.revetment; + const height = Number(spec?.height_m); + if (!spec || !Number.isFinite(height) || height <= 0) return null; + const side: "left" | "right" = spec.side === "우" ? "right" : "left"; + const top = fillToeAt(section, side); + if (!top) return null; + + const outward = side === "left" ? 1 : -1; + const bottomElevation = top.elevation - height - REVET_EMBED_DEPTH_M; + const frontTop = top.offset + outward * REVET_THICKNESS_M; + const frontBottom = frontTop + outward * REVET_LEAN_RATIO * (top.elevation - bottomElevation); + return { + side, + top, + polygon: [ + { offset: top.offset, elevation: top.elevation }, + { offset: frontTop, elevation: top.elevation }, + { offset: frontBottom, elevation: bottomElevation }, + { offset: top.offset, elevation: bottomElevation }, + ], + span: { + beforeM: Math.max(spec.anchor_m - spec.start_m, 0), + afterM: Math.max(spec.end_m - spec.anchor_m, 0), + }, + }; +} + +/** 횡단 카드에 벽 단면을 그린다. 그렸으면 true. */ +export function appendRevetmentOverlay( + svg: SVGElement, + layout: RevetmentLayout | null, + x: (offset: number) => number, + y: (elevation: number) => number, +): boolean { + if (!layout) return false; + const polygon = document.createElementNS(SVG_NS, "polygon"); + polygon.setAttribute( + "points", + layout.polygon.map((point) => `${x(point.offset)},${y(point.elevation)}`).join(" "), + ); + polygon.setAttribute("class", "b06-chart__revetment"); + svg.append(polygon); + return true; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index b1ef5754..9f778bea 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -15,6 +15,7 @@ import { type CrossAreaKey, } from "./B06_Section_UI_Cross_Areas"; import { appendFordPavementOverlay } from "./B06_Section_UI_Cross_Ford_Pavement"; +import { appendRevetmentOverlay, computeRevetmentLayout } from "./B06_Section_UI_Cross_Revetment"; import { appendCrossDesignOverlay, appendPavementOverlay, @@ -441,6 +442,8 @@ export function createCrossSectionCard( toDisplayY, ); if (!fordPaved) appendPavementOverlay(plotLayer, section.design, x, toDisplayY); + // 독립 기슭막이 — 성토면 끝에 서는 벽. 3D도 같은 폴리곤을 스윕한다. + appendRevetmentOverlay(plotLayer, computeRevetmentLayout(section), x, toDisplayY); appendCrossDesignOverlay( plotLayer, section.design, diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index 2c2dfdcb..7e4c0b75 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -164,6 +164,14 @@ stroke-width: 1; } +/* 독립 기슭막이(2026-08-28): 성토면 끝에 서는 벽 — 배관 세트 기슭막이와 같은 색 계열 */ +.b06-chart__revetment { + fill: color-mix(in srgb, #9b6bdc 22%, transparent); + stroke: #9b6bdc; + stroke-width: 1.4; + stroke-linejoin: round; +} + /* 물넘이포장(2026-08-28): 파인 노면 — 기존 계획고 점선 + 바닥 실선 + 진한 회색 빗금 포장 */ .b06-chart__ford-deck-plan { fill: none;