feat(B05,B06): 물넘이 3D 파임 + 독립 기슭막이 횡단·3D + 포장 기본값·표준횡단 반영
2026-08-28 사용자 지시 4건.
① 물넘이 파임을 3D 코리도에도 반영
classifyStation 이 노면·노견 조각 표고를 횡단도와 같은 산식(fordDeckElevationAt)
으로 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다. BUILD_VERSION 85.
② 표준횡단면 저장분을 포장 재계산에 싣는다
확정 때 종단 정본 options 에 실린 standard_cross_section 을 읽어
enforce_pavement_ranges / attach_default_designs 에 넘긴다. 없으면 config 기본값.
③ 포장 구간 기본값 10 / 5 / 5 (기슭막이와 같은 출발값).
④ 독립 기슭막이
- 측점: is_station_planting_type() 규칙 신설(A군 + D군 구간형). 구간형은
시작·기준·종료에 측점을 심는다 — 길이가 길수록 횡단도가 여러 장 나온다.
구조물 재이관 차단도 같은 규칙을 본다(기타 고스트 방지).
- 서버: B06_Section_Engine_Revetment 신설 — 구간 안 측점 전부에 section.revetment.
- 횡단도: B06_Section_UI_Cross_Revetment 신설 — 벽 상단 = 성토면 끝(설계선-지반
교차점), 아래로 높이 + 근입 0.5m, 전면 1:0.3(교본 7-3). 설치 측은 사용자가
좌/우로 고른다(레지스트리 side 옵션 신설 — 자동 판정 없음).
- 3D: 같은 폴리곤을 기준측점 전/후만큼 스윕. BUILD_VERSION 86 + 구조물 해시에
물넘이·기슭막이 제원 추가.
검증: pytest 241 passed / 7 skipped(신규 4건). 공용 브라우저 실측 —
물넘이 240m 투입 시 3D 노면이 계획고보다 0.38~0.42m 아래(대조군 200m 정상),
기슭막이 225~255 투입 시 측점 23→25·횡단 카드 3장·3D revet 솔리드 3개(31링).
임시 데이터는 원래 정본으로 복구했다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string | number> {
|
||||
// 물넘이 파임·독립 기슭막이는 다른 세트와 함께 설 수 있으니 앞에 이어 붙인다 —
|
||||
// 빼먹으면 값을 고쳐도 저장 코리도가 만료되지 않는다(2026-08-28).
|
||||
const extras: Array<string | number> = [];
|
||||
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<string | number
|
||||
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
|
||||
};
|
||||
return [
|
||||
...extras,
|
||||
"fd",
|
||||
ford.diameter_m,
|
||||
ford.pipe_count,
|
||||
@@ -609,7 +637,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
|
||||
];
|
||||
}
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return [];
|
||||
if (!culvert) return extras;
|
||||
const side = (spec: CulvertSideSpec): string =>
|
||||
[
|
||||
spec.structure,
|
||||
@@ -626,6 +654,7 @@ export function structureHashParts(section: CrossSection): Array<string | number
|
||||
const revet = section.design?.revet_adjust;
|
||||
const counts = section.design?.extra_wall_counts;
|
||||
return [
|
||||
...extras,
|
||||
"cv",
|
||||
culvert.diameter_m,
|
||||
culvert.pipe_kind ?? "",
|
||||
|
||||
@@ -640,6 +640,13 @@ export function createRouteViewer(): RouteViewer {
|
||||
})),
|
||||
outlineRows: build.outline.chainages.length,
|
||||
outline: build.outline,
|
||||
// 구조물 솔리드 요약 — 어떤 시설이 몇 개 섰는지 화면 밖에서 센다(2026-08-28).
|
||||
structures: (build.structures ?? []).map((solid) => ({
|
||||
kind: solid.kind,
|
||||
at: solid.chainage_m,
|
||||
rings: solid.rings?.length ?? 0,
|
||||
points: solid.polygon?.length ?? 0,
|
||||
})),
|
||||
// 원본 참조 — 투영선이 실제로 서피스에 얹혔는지 좌표로 대조할 때 쓴다.
|
||||
raw: {
|
||||
ribbons: build.ribbons,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user