feat(B05·B06): 세월교 횡단 구체·조정창·3D 예상형상
B05 세월교 옵션에 BOX암거 날개벽 한 벌(설치·짧은쪽 높이 1m·길이 2m·각도 45°)을 그대로 붙이고, B06 횡단도에 세월교 구체를 그린다. 구체는 양측 ㄴ형 집수정 측벽 + 그 사이를 잇는 바닥판 + 바닥판 상면에 안힌 관 + 관 위 성토(노체) 채움이다. - 바닥판 편측 연장 = 날개벽 길이 × cos(각도) — 각도는 관축 기준 벌어짐각 - 측벽은 buildBasin 재사용이라 1:0.3 기움·좌우·상하 대각 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다 (기슭막이 다단·재질과는 연계하지 않음) - 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후 절반까지 붙는다 - 조정창: 측벽 높이·좌우·상하, 관경·수량(정본 pipe_points 되쓰기). 높이·좌우·상하는 세션 + design.ford_adjust에 저장 - 3D 예상형상: 구체 부재는 월류 폭 구간 스윕, 관은 련수만큼 폭 안 등간격 - 부재 두께: 바닥판 0.3m(교본 물넘이 물받이 최소 30cm), 측벽 0.2m(집수정 승계) 700줄 제한으로 카드 렌더러·측점 제어기·페이지에서 배선을 분리했다 (_Cross_View_Ford, _Page_Ford_Controls, _Page_Patches, Culvert_Const 구간값 헬퍼). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -344,6 +344,70 @@
|
||||
"default": null,
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "wing_in",
|
||||
"label": "날개벽(유입)",
|
||||
"input": "select",
|
||||
"choices": ["있음", "없음"],
|
||||
"default": "있음",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "wing_in_height_m",
|
||||
"label": "유입 날개벽 짧은쪽 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "wing_in_length_m",
|
||||
"label": "유입 날개벽 길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "wing_in_angle_deg",
|
||||
"label": "유입 날개벽 각도",
|
||||
"input": "number",
|
||||
"unit": "°",
|
||||
"default": 45,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "wing_out",
|
||||
"label": "날개벽(유출)",
|
||||
"input": "select",
|
||||
"choices": ["있음", "없음"],
|
||||
"default": "있음",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "wing_out_height_m",
|
||||
"label": "유출 날개벽 짧은쪽 높이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 1,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "wing_out_length_m",
|
||||
"label": "유출 날개벽 길이",
|
||||
"input": "number",
|
||||
"unit": "m",
|
||||
"default": 2,
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"key": "wing_out_angle_deg",
|
||||
"label": "유출 날개벽 각도",
|
||||
"input": "number",
|
||||
"unit": "°",
|
||||
"default": 45,
|
||||
"required": false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -22,6 +22,11 @@ import type { WallAdjust } from "../B06_Section/B06_Section_UI_Cross_Culvert_Typ
|
||||
import { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
|
||||
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import type { CulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
|
||||
import {
|
||||
computeFordLayout,
|
||||
DEFAULT_FORD_WALL_ADJUST,
|
||||
} from "../B06_Section/B06_Section_UI_Cross_Ford";
|
||||
import type { FordLayout } from "../B06_Section/B06_Section_UI_Cross_Ford";
|
||||
|
||||
/** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
|
||||
export interface StructureFrame {
|
||||
@@ -98,6 +103,20 @@ function storedAdjusts(
|
||||
};
|
||||
}
|
||||
|
||||
/** 세월교 구체 기하 — 배수관과 같은 규칙으로 **정본 조작값만** 읽는다(2026-08-25). */
|
||||
export function fordLayoutOf(section: CrossSection): FordLayout | null {
|
||||
if (!section.ford || !section.design) return null;
|
||||
const stored = section.design.ford_adjust;
|
||||
try {
|
||||
return computeFordLayout(section, section.samples, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
||||
});
|
||||
} catch {
|
||||
return null; // 한 측점의 기하 실패가 3D 전체를 막으면 안 된다.
|
||||
}
|
||||
}
|
||||
|
||||
export function culvertLayoutOf(section: CrossSection): CulvertLayout | null {
|
||||
if (!section.culvert || !section.design) return null;
|
||||
const hash = structureHashParts(section).join("|");
|
||||
@@ -156,7 +175,8 @@ export function buildCorridorStructures(
|
||||
const solids: CorridorStructure[] = [];
|
||||
for (const section of crossSections) {
|
||||
const layout = culvertLayoutOf(section);
|
||||
if (!layout) continue;
|
||||
const fordLayout = fordLayoutOf(section);
|
||||
if (!layout && !fordLayout) continue;
|
||||
const chainage = section.chainage_m;
|
||||
const stationFrame: StructureFrame = {
|
||||
cx: section.center_x,
|
||||
@@ -193,10 +213,6 @@ export function buildCorridorStructures(
|
||||
return rings;
|
||||
};
|
||||
|
||||
const inletSpan = wallSpanOf(section, "inlet");
|
||||
const outletSpan = wallSpanOf(section, "outlet");
|
||||
const basinSpan = basinSpanOf(section);
|
||||
|
||||
const pushSwept = (
|
||||
kind: "revet" | "basin",
|
||||
points: Array<{ offset: number; elevation: number }>,
|
||||
@@ -212,6 +228,43 @@ export function buildCorridorStructures(
|
||||
});
|
||||
};
|
||||
|
||||
if (fordLayout) {
|
||||
// 구체는 월류 폭만큼 도로 방향으로 이어지고 기준 측점 전후로 절반씩 걸친다.
|
||||
const half = Math.max(fordLayout.ford.span_m, 0.5) / 2;
|
||||
for (const side of fordLayout.sides) {
|
||||
for (const part of side.parts) pushSwept("basin", part.points, half, half);
|
||||
}
|
||||
pushSwept("basin", fordLayout.slabBridge, half, half);
|
||||
// 관은 련수만큼 폭 안에 등간격으로 놓는다(단면엔 1개만 보이지만 실물은 여러 련).
|
||||
const count = Math.max(fordLayout.ford.pipe_count, 1);
|
||||
const invert = fordLayout.pipe.outer;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const at = chainage - half + (2 * half * (i + 0.5)) / count;
|
||||
const frame = frameAt(at);
|
||||
solids.push({
|
||||
chainage_m: at,
|
||||
kind: "pipe",
|
||||
pipe: {
|
||||
start: [invert[3].offset, invert[3].elevation],
|
||||
end: [invert[2].offset, invert[2].elevation],
|
||||
diameterM: fordLayout.ford.diameter_m,
|
||||
},
|
||||
frame: frame
|
||||
? {
|
||||
cx: frame.cx,
|
||||
cy: frame.cy,
|
||||
leftX: frame.leftX,
|
||||
leftY: frame.leftY,
|
||||
dz: baseZ != null && frame.designZ != null ? frame.designZ - baseZ : 0,
|
||||
}
|
||||
: stationFrame,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!layout) continue;
|
||||
const inletSpan = wallSpanOf(section, "inlet");
|
||||
const outletSpan = wallSpanOf(section, "outlet");
|
||||
const basinSpan = basinSpanOf(section);
|
||||
for (const wall of layout.walls) {
|
||||
const span = wall.role === "inlet" ? inletSpan : outletSpan;
|
||||
pushSwept("revet", wall.points, span.beforeM, span.afterM);
|
||||
@@ -269,6 +322,24 @@ export function buildCorridorStructures(
|
||||
|
||||
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
|
||||
export function structureHashParts(section: CrossSection): Array<string | number> {
|
||||
const ford = section.ford;
|
||||
if (ford) {
|
||||
const adjust = section.design?.ford_adjust;
|
||||
const wall = (role: "inlet" | "outlet"): string => {
|
||||
const value = adjust?.[role];
|
||||
return value ? `${value.heightM ?? ""},${value.lateralM},${value.slopeM}` : "";
|
||||
};
|
||||
return [
|
||||
"fd",
|
||||
ford.diameter_m,
|
||||
ford.pipe_count,
|
||||
ford.span_m,
|
||||
ford.wing_in.slab_extend_m,
|
||||
ford.wing_out.slab_extend_m,
|
||||
wall("inlet"),
|
||||
wall("outlet"),
|
||||
];
|
||||
}
|
||||
const culvert = section.culvert;
|
||||
if (!culvert) return [];
|
||||
const side = (spec: CulvertSideSpec): string =>
|
||||
|
||||
@@ -340,15 +340,17 @@ export function createFacilityOptionsForm(
|
||||
inletGroup.root.hidden = !isPipe;
|
||||
outletGroup.root.hidden = !isPipe;
|
||||
boxWrap.hidden = !isBox;
|
||||
wingInFields.root.hidden = !isBox;
|
||||
wingOutFields.root.hidden = !isBox;
|
||||
// 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정).
|
||||
const hasWing = isBox || current === "ford_bridge";
|
||||
wingInFields.root.hidden = !hasWing;
|
||||
wingOutFields.root.hidden = !hasWing;
|
||||
const isFord = current === "ford_pavement" || current === "ford_bridge";
|
||||
fordRow.hidden = current !== "ford_bridge";
|
||||
fordWidthRow.hidden = !isFord;
|
||||
fordSummary.hidden = !isFord;
|
||||
if (isFord) syncFordSummary();
|
||||
if (isBox) {
|
||||
syncBoxSize();
|
||||
if (isBox) syncBoxSize();
|
||||
if (hasWing) {
|
||||
wingInFields.syncVisibility();
|
||||
wingOutFields.syncVisibility();
|
||||
}
|
||||
@@ -477,6 +479,8 @@ export function createFacilityOptionsForm(
|
||||
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
|
||||
putNumber(options, "ford_width_m", fordWidth.value);
|
||||
putFordHeight(options);
|
||||
wingInFields.read(options);
|
||||
wingOutFields.read(options);
|
||||
}
|
||||
return options;
|
||||
},
|
||||
|
||||
@@ -222,12 +222,40 @@ export interface CulvertSet {
|
||||
outlet: CulvertSideSpec;
|
||||
}
|
||||
|
||||
/** 세월교 날개벽 한쪽 — 횡단면엔 안 보이고 바닥판 연장량만 넘긴다. */
|
||||
export interface FordWingSpec {
|
||||
installed: boolean;
|
||||
height_m: number | null;
|
||||
length_m: number | null;
|
||||
angle_deg: number | null;
|
||||
/** 바닥판 편측 연장(m) = 길이 × cos(각도). 각도는 관축 기준 벌어짐각. */
|
||||
slab_extend_m: number;
|
||||
}
|
||||
|
||||
/** 세월교 측점의 세트 제원 — 양측 ㄴ형 측벽 + 바닥판 + 관, 관 위는 성토 채움. */
|
||||
export interface FordSet {
|
||||
type: "ford";
|
||||
pipe_kind: string | null;
|
||||
diameter_m: number;
|
||||
/** 관 련수. 단면엔 1개만 그리고 라벨에만 쓴다. */
|
||||
pipe_count: number;
|
||||
/** 구체의 도로 진행 방향 길이(m) = 월류 폭. 기준 측점 전후로 절반씩 걸친다. */
|
||||
span_m: number;
|
||||
slab_thickness_m: number;
|
||||
wall_thickness_m: number;
|
||||
min_cover_m: number;
|
||||
wing_in: FordWingSpec;
|
||||
wing_out: FordWingSpec;
|
||||
}
|
||||
|
||||
export interface CrossSection extends SectionStation {
|
||||
samples: SectionSample[];
|
||||
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
|
||||
design?: CrossDesign;
|
||||
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
|
||||
culvert?: CulvertSet;
|
||||
/** 세월교 구체가 걸치는 측점의 세트 제원(있을 때만). 구체 폭 안이면 여러 측점에 붙는다. */
|
||||
ford?: FordSet;
|
||||
}
|
||||
|
||||
export interface SectionDetailResponse {
|
||||
@@ -265,6 +293,19 @@ export interface StoredExtraWallCounts {
|
||||
basin: number;
|
||||
}
|
||||
|
||||
/** 세월교 측벽 한 매의 저장형 조작값(2026-08-25). */
|
||||
export interface StoredFordWallAdjust {
|
||||
heightM: number | null;
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
}
|
||||
|
||||
/** 세월교 측점의 저장형 조작값 — 유입·유출 측벽을 따로 담는다. */
|
||||
export interface StoredFordAdjust {
|
||||
inlet: StoredFordWallAdjust;
|
||||
outlet: StoredFordWallAdjust;
|
||||
}
|
||||
|
||||
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
|
||||
export interface CrossDesign {
|
||||
inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
|
||||
@@ -274,6 +315,8 @@ export interface CrossDesign {
|
||||
lateralM: number;
|
||||
slopeM: number;
|
||||
};
|
||||
/** 세월교 측벽 조작값(유입·유출) — 높이·좌우·상하(2026-08-25). */
|
||||
ford_adjust?: StoredFordAdjust;
|
||||
/** 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
* 세션 전용이던 값을 정본에 남긴다(2026-08-24: 3D는 확정 결과물). */
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
@@ -382,6 +425,7 @@ export interface CrossSectionPatch {
|
||||
slopeM: number;
|
||||
};
|
||||
revet_adjust?: Record<string, StoredWallAdjust>;
|
||||
ford_adjust?: StoredFordAdjust;
|
||||
extra_wall_counts?: StoredExtraWallCounts;
|
||||
/** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */
|
||||
revet_link_detached?: boolean;
|
||||
|
||||
@@ -19,12 +19,17 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
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 common_util.common_util_drainage_pipes import (
|
||||
PIPE_FACILITY_FORD_BRIDGE,
|
||||
PIPE_FACILITY_PIPE,
|
||||
parse_pipe_points,
|
||||
)
|
||||
from config.config_system import (
|
||||
DRAINAGE_CACHE_DIRNAME,
|
||||
DRAINAGE_EDITS_DIRNAME,
|
||||
@@ -56,6 +61,13 @@ REVET_FACE_SLOPE = 0.3
|
||||
# 유입구 구조 선택지 — 레지스트리 `pipe.inlet_type` choices와 같은 문자열이다.
|
||||
INLET_STRUCTURE_BASIN = "집수정"
|
||||
|
||||
# ⚠ 교차 참조 ③ 세월교 바닥판 두께(m). 교본 물넘이 물받이 "최소 30㎝ 이상"을 끌어왔다
|
||||
# (2026-08-25 사용자 확정). 측벽 두께는 근거 없이 집수정 부재 두께를 승계한다.
|
||||
FORD_SLAB_THICKNESS_M = 0.3
|
||||
FORD_WALL_THICKNESS_M = 0.2
|
||||
# 월류 폭 기본값(m) — B05 폼이 세월교에 채우는 값과 같다.
|
||||
FORD_DEFAULT_WIDTH_M = 10.0
|
||||
|
||||
|
||||
def pipe_points_file(project_root: Path) -> Path:
|
||||
"""프로젝트 저장소 안의 관 지점 정본 경로."""
|
||||
@@ -68,17 +80,22 @@ def pipe_points_file(project_root: Path) -> Path:
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _registry_pipe_defaults() -> dict[str, Any]:
|
||||
"""레지스트리 `pipe` 타입 옵션의 기본값 묶음.
|
||||
@lru_cache(maxsize=4)
|
||||
def _registry_defaults(type_id: str) -> dict[str, Any]:
|
||||
"""레지스트리 타입 옵션의 기본값 묶음.
|
||||
|
||||
저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는
|
||||
기본값과 항상 같은 값이 그림에 들어간다.
|
||||
"""
|
||||
pipe_type = structure_type_map().get("pipe")
|
||||
if pipe_type is None:
|
||||
structure_type = structure_type_map().get(type_id)
|
||||
if structure_type is None:
|
||||
return {}
|
||||
return {option.key: option.default for option in pipe_type.options}
|
||||
return {option.key: option.default for option in structure_type.options}
|
||||
|
||||
|
||||
def _registry_pipe_defaults() -> dict[str, Any]:
|
||||
"""레지스트리 `pipe` 타입 옵션 기본값."""
|
||||
return _registry_defaults("pipe")
|
||||
|
||||
|
||||
def _number(value: Any, fallback: float | None) -> float | None:
|
||||
@@ -174,6 +191,63 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
|
||||
"""날개벽 한쪽 제원 + 그 각도가 만드는 바닥판 연장량.
|
||||
|
||||
날개벽은 횡단면에 보이지 않지만(도로 진행 방향으로 벌어진다), 벌어진 만큼
|
||||
바닥판이 계류 상·하류로 길어진다 — 연장량 = 길이 × cos(각도)로,
|
||||
각도는 관축(계류 방향) 기준 벌어짐각이다 (2026-08-25 사용자 확정).
|
||||
"""
|
||||
prefix = f"wing_{side}"
|
||||
install = values.get(prefix, defaults.get(prefix))
|
||||
length = _number(
|
||||
values.get(f"{prefix}_length_m"), _number(defaults.get(f"{prefix}_length_m"), 0.0)
|
||||
)
|
||||
angle = _number(
|
||||
values.get(f"{prefix}_angle_deg"), _number(defaults.get(f"{prefix}_angle_deg"), 45.0)
|
||||
)
|
||||
height = _number(
|
||||
values.get(f"{prefix}_height_m"), _number(defaults.get(f"{prefix}_height_m"), 0.0)
|
||||
)
|
||||
installed = str(install or "").strip() != "없음"
|
||||
extend = (length or 0.0) * math.cos(math.radians(angle or 0.0)) if installed else 0.0
|
||||
return {
|
||||
"installed": installed,
|
||||
"height_m": height,
|
||||
"length_m": length,
|
||||
"angle_deg": angle,
|
||||
"slab_extend_m": round(max(extend, 0.0), 3),
|
||||
}
|
||||
|
||||
|
||||
def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장).
|
||||
|
||||
측벽은 ㄴ형 집수정 형상을 양쪽에 쓰고, 관 위는 성토(노체)로 채운다.
|
||||
구체는 월류 폭만큼 도로 방향으로 이어지며 기준 측점 전후로 절반씩 걸친다.
|
||||
"""
|
||||
defaults = _registry_defaults("ford_bridge")
|
||||
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")
|
||||
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
|
||||
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
|
||||
return {
|
||||
"type": "ford",
|
||||
"pipe_kind": str(kind) if kind else None,
|
||||
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
|
||||
"pipe_count": max(int(count), 1) if count else 1,
|
||||
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
|
||||
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
|
||||
"wall_thickness_m": FORD_WALL_THICKNESS_M,
|
||||
"min_cover_m": MIN_PIPE_COVER_M,
|
||||
"wing_in": _wing_spec(values, defaults, "in"),
|
||||
"wing_out": _wing_spec(values, defaults, "out"),
|
||||
}
|
||||
|
||||
|
||||
def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
|
||||
"""관 지점 정본을 읽어 누가거리별 세트 제원을 돌려준다.
|
||||
|
||||
@@ -190,10 +264,14 @@ def load_culvert_sets(project_root: Path) -> dict[float, dict[str, Any]]:
|
||||
return {}
|
||||
sets: dict[float, dict[str, Any]] = {}
|
||||
for point in parse_pipe_points(document.get("points")):
|
||||
# BOX암거·물넘이포장·세월교는 그림이 다르다 — 이번 범위는 배관뿐이다.
|
||||
if point.facility != PIPE_FACILITY_PIPE:
|
||||
# BOX암거·물넘이포장은 그림이 다르다 — 배관과 세월교만 그린다.
|
||||
if point.facility == PIPE_FACILITY_PIPE:
|
||||
spec = _culvert_set(point.options)
|
||||
elif point.facility == PIPE_FACILITY_FORD_BRIDGE:
|
||||
spec = _ford_set(point.options)
|
||||
else:
|
||||
continue
|
||||
sets[round(float(point.chainage_m), 2)] = _culvert_set(point.options)
|
||||
sets[round(float(point.chainage_m), 2)] = spec
|
||||
return sets
|
||||
|
||||
|
||||
@@ -208,8 +286,11 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
|
||||
if chainage is None:
|
||||
continue
|
||||
for pipe_chainage, spec in sets.items():
|
||||
if abs(chainage - pipe_chainage) <= _CHAINAGE_TOLERANCE_M:
|
||||
section["culvert"] = spec
|
||||
# 세월교 구체는 월류 폭만큼 이어지므로 기준 측점 전후 절반까지 같은 단면이다.
|
||||
reach = _CHAINAGE_TOLERANCE_M + (_number(spec.get("span_m"), 0.0) or 0.0) / 2
|
||||
if abs(chainage - pipe_chainage) <= reach:
|
||||
# 세월교는 배수관과 그림이 달라 키를 나눈다 — 소비처가 섞이지 않는다.
|
||||
section["ford" if spec.get("type") == "ford" else "culvert"] = spec
|
||||
attached += 1
|
||||
break
|
||||
return attached
|
||||
|
||||
@@ -663,6 +663,7 @@ async def compute_cross_section_design(
|
||||
"display_half_width_m",
|
||||
"inlet_structure",
|
||||
"basin_adjust",
|
||||
"ford_adjust",
|
||||
"revet_adjust",
|
||||
"extra_wall_counts",
|
||||
"revet_link_detached",
|
||||
|
||||
@@ -126,6 +126,8 @@ async def _apply_section_edits(
|
||||
patch["revet_adjust"] = {
|
||||
role: adjust.model_dump() for role, adjust in patch_item.revet_adjust.items()
|
||||
}
|
||||
if patch_item.ford_adjust is not None:
|
||||
patch["ford_adjust"] = patch_item.ford_adjust.model_dump()
|
||||
if patch_item.extra_wall_counts is not None:
|
||||
patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump()
|
||||
if patch_item.revet_link_detached is not None:
|
||||
|
||||
@@ -140,4 +140,6 @@ def recompute_designs_for_alignment(
|
||||
design["inlet_structure"] = stored["inlet_structure"]
|
||||
if stored.get("basin_adjust") is not None:
|
||||
design["basin_adjust"] = stored["basin_adjust"]
|
||||
if stored.get("ford_adjust") is not None:
|
||||
design["ford_adjust"] = stored["ford_adjust"]
|
||||
section["design"] = design
|
||||
|
||||
@@ -91,6 +91,21 @@ class ExtraWallCountsPatch(BaseModel):
|
||||
basin: int = Field(default=0, ge=0, le=9)
|
||||
|
||||
|
||||
class FordWallAdjustPatch(BaseModel):
|
||||
"""세월교 측벽 한 매의 조작값 — 높이(자동이면 None)·좌우·상하(2026-08-25)."""
|
||||
|
||||
heightM: float | None = Field(default=None, ge=0, le=20)
|
||||
lateralM: float = Field(default=0, ge=-20, le=20)
|
||||
slopeM: float = Field(default=0, ge=-20, le=20)
|
||||
|
||||
|
||||
class FordAdjustPatch(BaseModel):
|
||||
"""세월교 측점의 유입·유출 측벽 조작값."""
|
||||
|
||||
inlet: FordWallAdjustPatch = Field(default_factory=FordWallAdjustPatch)
|
||||
outlet: FordWallAdjustPatch = Field(default_factory=FordWallAdjustPatch)
|
||||
|
||||
|
||||
class CrossSectionPatch(BaseModel):
|
||||
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
|
||||
|
||||
@@ -104,6 +119,8 @@ class CrossSectionPatch(BaseModel):
|
||||
# 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
|
||||
# 세션 전용이던 값을 정본으로 올린다(2026-08-24 사용자: 3D는 확정 결과물).
|
||||
revet_adjust: dict[str, WallAdjustPatch] | None = None
|
||||
# 세월교 측벽 조작값(유입·유출) — 2026-08-25 사용자.
|
||||
ford_adjust: FordAdjustPatch | None = None
|
||||
extra_wall_counts: ExtraWallCountsPatch | None = None
|
||||
# 연동 기슭막이 옵션(2026-08-24 사용자). 연동 해제는 측점별, 종단경사 반영은
|
||||
# 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다.
|
||||
|
||||
@@ -102,6 +102,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
|
||||
put("inlet_structure", design.inlet_structure);
|
||||
put("basin_adjust", design.basin_adjust);
|
||||
put("revet_adjust", design.revet_adjust);
|
||||
put("ford_adjust", design.ford_adjust);
|
||||
put("extra_wall_counts", design.extra_wall_counts);
|
||||
put("revet_link_detached", design.revet_link_detached);
|
||||
put("revet_follow_grade", design.revet_follow_grade);
|
||||
|
||||
@@ -84,6 +84,10 @@ export interface BasinBuildInput {
|
||||
/** 표준단면 절토 경사(1:n) — 절토 계획선용. */
|
||||
cutSlopeRatio: number;
|
||||
adjust: BasinAdjust;
|
||||
/** 벽 두께(m). 기본 = 집수정 부재 두께. 세월교 측벽이 다른 두께를 쓸 때 넘긴다. */
|
||||
memberThicknessM?: number;
|
||||
/** 바닥판 두께(m). 기본 = 벽 두께. 세월교는 교본 물받이 최소 0.3m를 쓴다. */
|
||||
floorThicknessM?: number;
|
||||
}
|
||||
|
||||
/** 집수정 구성 결과 — 본체가 트림·관 끝단면·관 시작점에 나눠 꽂는다. */
|
||||
@@ -128,8 +132,8 @@ export function buildBasin(input: BasinBuildInput): BasinBuildResult {
|
||||
elevation: baseAnchor.elevation - adjust.slopeM / 1.2,
|
||||
};
|
||||
const anchor = movedAnchor;
|
||||
const memberT = BASIN_MEMBER_THICKNESS_M;
|
||||
const floorThickness = memberT;
|
||||
const memberT = input.memberThicknessM ?? BASIN_MEMBER_THICKNESS_M;
|
||||
const floorThickness = input.floorThicknessM ?? memberT;
|
||||
// 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값).
|
||||
const wallHeight =
|
||||
shape === "I"
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* 분리했다(700줄 제한). 값의 근거는 각 상수 주석에 지식DB 출처로 남긴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CulvertSideSpec } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossSection, CulvertSideSpec } from "./B06_Section_Api_Fetch";
|
||||
import type { SpanRole, SpanValues } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
|
||||
/** 관 위 최소 토피(m) — 프론트 정의처(백엔드는 `B06_Section_Engine_Culvert.MIN_PIPE_COVER_M`).
|
||||
* ⚠ 임도 배수관 토피 규정이 지식DB에 없어 별표2 교량·암거 "복토 50㎝ 이상"을 교차
|
||||
@@ -230,3 +231,35 @@ export function basinSpanOfSpec(spec: CulvertSideSpec | undefined): StructureSpa
|
||||
const length = Math.max(spec?.basin_length_m ?? 2, 0.1);
|
||||
return spanFrom(length, spec?.basin_before_m, spec?.basin_after_m);
|
||||
}
|
||||
|
||||
/** 역할별 옵션 키 — 배수관 레지스트리 옵션 이름 그대로다. */
|
||||
export const SPAN_OPTION_KEYS: Record<SpanRole, { length: string; before: string; after: string }> =
|
||||
{
|
||||
inlet: {
|
||||
length: "inlet_revet_length_m",
|
||||
before: "inlet_revet_before_m",
|
||||
after: "inlet_revet_after_m",
|
||||
},
|
||||
outlet: {
|
||||
length: "outlet_revet_length_m",
|
||||
before: "outlet_revet_before_m",
|
||||
after: "outlet_revet_after_m",
|
||||
},
|
||||
basin: {
|
||||
length: "inlet_basin_length_m",
|
||||
before: "inlet_basin_before_m",
|
||||
after: "inlet_basin_after_m",
|
||||
},
|
||||
};
|
||||
|
||||
/** 소유 측점의 구간값(길이·전/후) — 저장 옵션과 기본값을 함께 푼 결과. */
|
||||
export const spanValuesOf = (owner: CrossSection, role: SpanRole): SpanValues | null => {
|
||||
const spec = role === "outlet" ? owner.culvert?.outlet : owner.culvert?.inlet;
|
||||
if (!spec) return null;
|
||||
const span = role === "basin" ? basinSpanOfSpec(spec) : revetSpanOfSpec(spec);
|
||||
return {
|
||||
lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10,
|
||||
beforeM: Math.round(span.beforeM * 10) / 10,
|
||||
afterM: Math.round(span.afterM * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Ford.ts
|
||||
* 세월교 측점 횡단 카드의 구체(측벽·바닥판·관·성토 채움) **오버레이 그리기**.
|
||||
*
|
||||
* 기하 계산은 `B06_Section_UI_Cross_Ford_Geom.ts`가 맡는다(700줄 제한 분리).
|
||||
* 여기서는 계산 결과(`FordLayout`)를 SVG 도형으로 옮기기만 한다 — 치수 결정 금지.
|
||||
*
|
||||
* 그리는 순서 = 시공 순서: ① 바닥판 → ② 측벽(ㄴ형 집수정 재사용) → ③ 관 →
|
||||
* ④ 성토(노체) 채움 → ⑤ 계류측 성토부선·절토선.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FordLayout, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
import type { OffsetPoint } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
|
||||
export {
|
||||
computeFordLayout,
|
||||
DEFAULT_FORD_ADJUST,
|
||||
DEFAULT_FORD_WALL_ADJUST,
|
||||
} from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
export type {
|
||||
FordAdjust,
|
||||
FordLayout,
|
||||
FordSideLayout,
|
||||
FordWallAdjust,
|
||||
FordWallRole,
|
||||
} from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
/** 벽 강조를 카드 재생성 없이 갈아 끼우는 setter. */
|
||||
export type FordHighlightSetter = (key: FordWallRole | null) => void;
|
||||
|
||||
/**
|
||||
* 세월교 구체 오버레이 — computeFordLayout 결과를 그린다.
|
||||
*
|
||||
* `onSelectWall`이 오면 측벽이 **선택 대상**이 된다(집수정과 같은 규칙).
|
||||
* 클릭은 여기서 멈춘다 — 밑에 깔린 면적 밴드로 번지면 강조가 딴 것으로 바뀐다.
|
||||
*/
|
||||
export function appendFordOverlay(
|
||||
layer: SVGElement,
|
||||
layout: FordLayout,
|
||||
x: (offset: number) => number,
|
||||
toDisplayY: (elevation: number) => number,
|
||||
onSelectWall?: (key: FordWallRole) => void,
|
||||
): FordHighlightSetter {
|
||||
const { ford } = layout;
|
||||
const toXY = (point: OffsetPoint): [number, number] => [
|
||||
x(point.offset),
|
||||
toDisplayY(point.elevation),
|
||||
];
|
||||
const polygon = (
|
||||
points: OffsetPoint[],
|
||||
className: string,
|
||||
tooltip: string,
|
||||
): SVGPolygonElement => {
|
||||
const shape = document.createElementNS(SVG_NS, "polygon");
|
||||
shape.setAttribute("points", points.map((point) => toXY(point).join(",")).join(" "));
|
||||
shape.setAttribute("class", className);
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = tooltip;
|
||||
shape.append(title);
|
||||
return shape;
|
||||
};
|
||||
const planLine = (points: OffsetPoint[], tooltip: string): void => {
|
||||
if (points.length < 2) return;
|
||||
const line = document.createElementNS(SVG_NS, "polyline");
|
||||
line.setAttribute("points", points.map((point) => toXY(point).join(",")).join(" "));
|
||||
// 성토·절토 접속선은 공사 계획선 — 설계선과 같은 보라 실선.
|
||||
line.setAttribute("class", "b06-chart__design-cross");
|
||||
const title = document.createElementNS(SVG_NS, "title");
|
||||
title.textContent = tooltip;
|
||||
line.append(title);
|
||||
layer.append(line);
|
||||
};
|
||||
|
||||
const wingText = (wing: {
|
||||
installed: boolean;
|
||||
length_m: number | null;
|
||||
angle_deg: number | null;
|
||||
}): string => (wing.installed ? `${wing.length_m ?? 0}m·${wing.angle_deg ?? 0}°` : "없음");
|
||||
|
||||
// ① 바닥판 — 두 측벽 사이를 잇는 판. 각 측 바깥 연장은 측벽 부재로 함께 그린다.
|
||||
layer.append(
|
||||
polygon(
|
||||
layout.slabBridge,
|
||||
"b06-chart__ford-slab",
|
||||
`세월교 바닥판 — 길이 ${layout.slabLengthM.toFixed(2)}m · 두께 ${ford.slab_thickness_m.toFixed(2)}m` +
|
||||
` (날개벽 유입 ${wingText(ford.wing_in)} / 유출 ${wingText(ford.wing_out)} 투영 연장 반영)`,
|
||||
),
|
||||
);
|
||||
|
||||
// ② 측벽 — ㄴ형 집수정 부재(기운 벽 + 바닥판)를 좌우에 하나씩.
|
||||
const walls = new Map<FordWallRole, SVGPolygonElement[]>();
|
||||
for (const side of layout.sides) {
|
||||
const role = side.role === "inlet" ? "유입" : "유출";
|
||||
const shapes: SVGPolygonElement[] = [];
|
||||
for (const part of side.parts) {
|
||||
const shape = polygon(
|
||||
part.points,
|
||||
`b06-chart__culvert-basin${onSelectWall ? " is-selectable" : ""}`,
|
||||
`${role} 측벽(ㄴ형) ${part.kind === "wall" ? "벽" : "바닥판"} — 높이 ${side.heightM.toFixed(2)}m` +
|
||||
` · 두께 ${ford.wall_thickness_m.toFixed(2)}m` +
|
||||
(onSelectWall ? " (클릭 후 조정창에서 높이·좌우·상하 조정)" : ""),
|
||||
);
|
||||
if (onSelectWall) {
|
||||
shape.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onSelectWall(side.role);
|
||||
});
|
||||
}
|
||||
shapes.push(shape);
|
||||
layer.append(shape);
|
||||
}
|
||||
walls.set(side.role, shapes);
|
||||
}
|
||||
|
||||
// ③ 관 — 바닥판 상면에 안힌다. 단면엔 1개만, 련수는 라벨로 알린다.
|
||||
layer.append(
|
||||
polygon(
|
||||
layout.pipe.outer,
|
||||
"b06-chart__culvert-pipe b06-chart__culvert-pipe--outer",
|
||||
`${layout.label.text} — 관 밑이 바닥판 상면`,
|
||||
),
|
||||
polygon(layout.pipe.inner, "b06-chart__culvert-pipe", layout.label.text),
|
||||
);
|
||||
|
||||
// ④ 성토(노체) 채움 — 관 위 ~ 노면 아래, 두 측벽 도로측 면 사이.
|
||||
layer.append(polygon(layout.fill, "b06-chart__ford-fill", "세월교 관 위 성토(노체) 채움"));
|
||||
|
||||
// ⑤ 계류측 마감선 — 구조물이 원지반 위면 성토부선, 안이면 절토선(집수정과 같은 체계).
|
||||
for (const side of layout.sides) {
|
||||
const role = side.role === "inlet" ? "유입" : "유출";
|
||||
for (const segment of side.fillSegments) {
|
||||
if (segment.kind === "cut") continue;
|
||||
planLine(
|
||||
segment.points,
|
||||
`${role} 측벽 계류측 성토부선 — 사면길이 ${segment.lengthM.toFixed(2)}m`,
|
||||
);
|
||||
}
|
||||
if (side.cutLine) {
|
||||
planLine([side.cutLine.from, side.cutLine.to], `${role} 측벽 절토선(계획선)`);
|
||||
}
|
||||
}
|
||||
|
||||
// 관·채움이 벽 위에 겹쳐 클릭을 가로챈다 — 투명 겹면을 맨 위에 얹어 벽을 고른다
|
||||
// (배수관 기슭막이 `culvert-revet-hit`와 같은 수법).
|
||||
if (onSelectWall) {
|
||||
for (const side of layout.sides) {
|
||||
const wall = side.parts.find((part) => part.kind === "wall");
|
||||
if (!wall) continue;
|
||||
const hit = polygon(wall.points, "b06-chart__culvert-revet-hit", "");
|
||||
hit.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
onSelectWall(side.role);
|
||||
});
|
||||
layer.append(hit);
|
||||
}
|
||||
}
|
||||
|
||||
const label = document.createElementNS(SVG_NS, "text");
|
||||
const [labelX, labelY] = toXY(layout.label.at);
|
||||
label.setAttribute("x", String(labelX));
|
||||
label.setAttribute("y", String(labelY));
|
||||
label.setAttribute("text-anchor", "middle");
|
||||
label.setAttribute("class", "b06-chart__culvert-label");
|
||||
label.textContent = layout.label.text;
|
||||
layer.append(label);
|
||||
|
||||
return (key: FordWallRole | null): void => {
|
||||
for (const [role, shapes] of walls) {
|
||||
for (const shape of shapes) shape.classList.toggle("is-active", role === key);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Ford_Geom.ts
|
||||
* 세월교 구체 **기하 계산** — 그리기(`_Cross_Ford.ts`)와 분리(700줄 제한).
|
||||
* 백엔드 `section.ford` 제원을 좌표로 옮긴다(치수 결정 금지).
|
||||
*
|
||||
* 구성(2026-08-25 사용자 확정):
|
||||
* · 양측 **측벽** = ㄴ형 집수정을 그대로 재사용한다(`buildBasin`) — 전면 1:0.3
|
||||
* 기움·좌우·상하(대각) 이동·계류측 성토부선이 집수정과 같은 규칙으로 따라온다.
|
||||
* · **바닥판** = 두 측벽 사이를 잇는 판 + 각 측 바깥으로 **날개벽 투영**(길이×cos각)
|
||||
* 만큼 뻗은 판. 집수정 내공 폭 자리에 그 연장량을 넣어 같은 기하로 만든다.
|
||||
* · **관**은 바닥판 상면(측벽 바깥면)에 안히고, 관 위 ~ 노면 아래는 **성토(노체)** 다.
|
||||
* · 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후로 절반씩 걸친다
|
||||
* (붙는 측점 판정은 백엔드 `attach_culvert_sets`).
|
||||
* 성토측이라도 기슭막이 세트(다단·재질)와는 연계하지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, FordSet, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
import { pipeWallThicknessM } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type {
|
||||
BasinLayout,
|
||||
CulvertDesignTrim,
|
||||
OffsetPoint,
|
||||
OutletFillSegment,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import { buildBasin } from "./B06_Section_UI_Cross_Culvert_Basin";
|
||||
import { buildExtrasAt } from "./B06_Section_UI_Cross_Culvert_Extra";
|
||||
import { designInterpolator, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
|
||||
|
||||
/** 측벽 한 매의 조작값 — 집수정 9키와 같은 축(높이·좌우·상하 대각). */
|
||||
export interface FordWallAdjust {
|
||||
/** 벽 높이(m, 노견 상단 ~ 바닥판 상면). null = 자동(바닥판이 계류 하상에 놓임). */
|
||||
heightM: number | null;
|
||||
/** 좌우(m, + = 노견 바깥). 안쪽으로는 못 간다. */
|
||||
lateralM: number;
|
||||
/** 상하(m, 수평 성분, + = 사면 아래) — 집수정과 같이 1:1.2 대각으로 내려간다. */
|
||||
slopeM: number;
|
||||
}
|
||||
|
||||
/** 측점별 세월교 조정값 — 유입·유출 측벽을 따로 잡는다. */
|
||||
export interface FordAdjust {
|
||||
inlet: FordWallAdjust;
|
||||
outlet: FordWallAdjust;
|
||||
}
|
||||
|
||||
export const DEFAULT_FORD_WALL_ADJUST: FordWallAdjust = {
|
||||
heightM: null,
|
||||
lateralM: 0,
|
||||
slopeM: 0,
|
||||
};
|
||||
|
||||
export const DEFAULT_FORD_ADJUST: FordAdjust = {
|
||||
inlet: DEFAULT_FORD_WALL_ADJUST,
|
||||
outlet: DEFAULT_FORD_WALL_ADJUST,
|
||||
};
|
||||
|
||||
export type FordWallRole = "inlet" | "outlet";
|
||||
|
||||
export interface FordSideLayout {
|
||||
role: FordWallRole;
|
||||
/** 계류측 방향 부호(+ = 좌). */
|
||||
outward: number;
|
||||
/** 벽·바닥판 폴리곤(집수정 ㄴ형과 같은 부재 구성). */
|
||||
parts: BasinLayout["parts"];
|
||||
/** 벽 상단 도로측 꼭짓점 = 설계선 트림 경계. */
|
||||
top: OffsetPoint;
|
||||
heightM: number;
|
||||
/** 바닥판 상면 표고(이동 반영) = 이쪽 관 끝의 invert. */
|
||||
slabTopElevation: number;
|
||||
/** 관 끝점(벽 바깥면 × 바닥판 상면). */
|
||||
pipeEnd: OffsetPoint;
|
||||
/** 벽 도로측 면(하단 → 상단) — 성토 채움의 옆면. */
|
||||
roadFace: [OffsetPoint, OffsetPoint];
|
||||
/** 바닥판 안쪽 변(위·아래) — 두 측벽 사이를 잇는 판이 여기서 시작한다. */
|
||||
slabInner: [OffsetPoint, OffsetPoint];
|
||||
/** 구조물 계류측 끝이 원지반 위일 때의 성토부선(집수정과 같은 체계). */
|
||||
fillSegments: OutletFillSegment[];
|
||||
/** 원지반 안으로 박힐 때의 절토선. */
|
||||
cutLine: BasinLayout["cutLine"];
|
||||
/** 한계에 걸려 실제 적용된 조작값. */
|
||||
adjust: FordWallAdjust;
|
||||
}
|
||||
|
||||
export interface FordLayout {
|
||||
ford: FordSet;
|
||||
sides: FordSideLayout[];
|
||||
/** 두 측벽 사이를 잇는 바닥판. */
|
||||
slabBridge: OffsetPoint[];
|
||||
/** 바닥판 전체 길이(m) — 날개벽 연장 포함. */
|
||||
slabLengthM: number;
|
||||
/** 관 외곽·내경 사각형(양 끝은 각 측 바닥판 상면). */
|
||||
pipe: { outer: OffsetPoint[]; inner: OffsetPoint[] };
|
||||
/** 관 위 성토(노체) 채움 폴리곤. */
|
||||
fill: OffsetPoint[];
|
||||
label: { at: OffsetPoint; text: string };
|
||||
designTrim: CulvertDesignTrim;
|
||||
adjust: FordAdjust;
|
||||
}
|
||||
|
||||
/** 측벽 높이 하한 = 관경 + 최소 토피. 관이 노면 위로 솟지 않게 막는다. */
|
||||
function minWallHeight(ford: FordSet): number {
|
||||
return ford.diameter_m + ford.min_cover_m;
|
||||
}
|
||||
|
||||
/** 성토 채움 윗면을 만들 노면 표고 — 설계선이 있으면 그 곡선, 없으면 노견 직선. */
|
||||
function roadTopFactory(
|
||||
section: CrossSection,
|
||||
left: { offset_m: number; elevation_m: number },
|
||||
right: { offset_m: number; elevation_m: number },
|
||||
): (offset: number) => number {
|
||||
const designAt = designInterpolator(section.design?.design_line);
|
||||
if (designAt) return designAt;
|
||||
const span = left.offset_m - right.offset_m;
|
||||
return (offset: number): number => {
|
||||
if (Math.abs(span) < 1e-6) return left.elevation_m;
|
||||
const t = (offset - right.offset_m) / span;
|
||||
return right.elevation_m + (left.elevation_m - right.elevation_m) * t;
|
||||
};
|
||||
}
|
||||
|
||||
/** 폴리라인에서 표고 하나에 대응하는 offset(선형 보간). */
|
||||
function offsetAtElevation(from: OffsetPoint, to: OffsetPoint, elevation: number): number {
|
||||
const span = to.elevation - from.elevation;
|
||||
if (Math.abs(span) < 1e-9) return from.offset;
|
||||
const t = (elevation - from.elevation) / span;
|
||||
return from.offset + (to.offset - from.offset) * t;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세월교 구체 기하. 제원·설계선·지반이 부족하면 null (그리기와 분리 — 설계선 트림이
|
||||
* 먼저 쓴다).
|
||||
*/
|
||||
export function computeFordLayout(
|
||||
section: CrossSection,
|
||||
groundSamples: SectionSample[],
|
||||
adjust: FordAdjust = DEFAULT_FORD_ADJUST,
|
||||
): FordLayout | null {
|
||||
const ford = section.ford;
|
||||
if (!ford) return null;
|
||||
const edges = section.design?.road_edges;
|
||||
if (!edges) return null;
|
||||
const groundAt = groundInterpolator(groundSamples);
|
||||
if (!groundAt) return null;
|
||||
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 inletOnLeft = uphill === "left";
|
||||
const roadTopAt = roadTopFactory(section, edges.left, edges.right);
|
||||
const cutSlopeRatio = section.design?.cut_slope_ratio ?? 1.0;
|
||||
|
||||
// 바닥판이 놓일 계류 하상 — 두 노견 사이 원지반 최저점.
|
||||
const bedElevation = (() => {
|
||||
let low = Math.min(groundAt(edges.left.offset_m), groundAt(edges.right.offset_m));
|
||||
for (let offset = edges.right.offset_m; offset <= edges.left.offset_m; offset += 0.25) {
|
||||
low = Math.min(low, groundAt(offset));
|
||||
}
|
||||
return low;
|
||||
})();
|
||||
|
||||
const buildSide = (role: FordWallRole): FordSideLayout | null => {
|
||||
const onLeft = role === "inlet" ? inletOnLeft : !inletOnLeft;
|
||||
const edge = onLeft ? edges.left : edges.right;
|
||||
const outward = onLeft ? 1 : -1;
|
||||
const limitOffset = onLeft ? maxSample : minSample;
|
||||
const wing = role === "inlet" ? ford.wing_in : ford.wing_out;
|
||||
const wanted = role === "inlet" ? adjust.inlet : adjust.outlet;
|
||||
const lateralM = Math.max(wanted.lateralM, 0);
|
||||
const autoHeight = roadTopAt(edge.offset_m) - (bedElevation + ford.slab_thickness_m);
|
||||
const heightM = Math.max(wanted.heightM ?? autoHeight, minWallHeight(ford));
|
||||
// 벽 상단이 노견에 붙도록 바닥판 상면을 역산한다. 상하 이동은 buildBasin이 얹는다.
|
||||
const anchor: OffsetPoint = {
|
||||
offset: edge.offset_m,
|
||||
elevation: roadTopAt(edge.offset_m) - heightM,
|
||||
};
|
||||
const built = buildBasin({
|
||||
anchor,
|
||||
outward,
|
||||
shape: "L",
|
||||
reason: "manual",
|
||||
diameterM: ford.diameter_m,
|
||||
edge: { offset_m: edge.offset_m, elevation_m: roadTopAt(edge.offset_m) },
|
||||
groundAt,
|
||||
cutSlopeRatio,
|
||||
// 내공 폭 자리에 날개벽 투영 연장을 넣는다 — 바닥판이 그만큼 계류측으로 뻗는다.
|
||||
adjust: {
|
||||
innerWidthM: Math.max(wing.slab_extend_m, 0),
|
||||
innerHeightM: heightM,
|
||||
lateralM,
|
||||
slopeM: wanted.slopeM,
|
||||
},
|
||||
memberThicknessM: ford.wall_thickness_m,
|
||||
floorThicknessM: ford.slab_thickness_m,
|
||||
});
|
||||
const wall = built.basin.parts.find((part) => part.kind === "wall");
|
||||
const floor = built.basin.parts.find((part) => part.kind === "floor");
|
||||
if (!wall || !floor) return null;
|
||||
const extras = buildExtrasAt(built.fillStart, {
|
||||
startBottomElevation: built.fillBottomElevation,
|
||||
outward,
|
||||
groundAt,
|
||||
limitOffset,
|
||||
adjusts: [],
|
||||
equalize: false,
|
||||
});
|
||||
return {
|
||||
role,
|
||||
outward,
|
||||
parts: built.basin.parts,
|
||||
top: { offset: built.trimOffset, elevation: built.trimElevation },
|
||||
heightM,
|
||||
slabTopElevation: built.pipeEnd.elevation,
|
||||
pipeEnd: built.pipeEnd,
|
||||
// 벽 폴리곤 꼭짓점 = [도로측 하단, 도로측 상단, 계류측 상단, 계류측 하단].
|
||||
roadFace: [wall.points[0], wall.points[1]],
|
||||
slabInner: [floor.points[0], floor.points[3]],
|
||||
fillSegments: extras.segments,
|
||||
cutLine: built.basin.cutLine,
|
||||
adjust: {
|
||||
heightM: wanted.heightM === null ? null : heightM,
|
||||
lateralM,
|
||||
slopeM: wanted.slopeM,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const inlet = buildSide("inlet");
|
||||
const outlet = buildSide("outlet");
|
||||
if (!inlet || !outlet) return null;
|
||||
const sides = [inlet, outlet];
|
||||
const left = inlet.outward > 0 ? inlet : outlet;
|
||||
const right = inlet.outward > 0 ? outlet : inlet;
|
||||
|
||||
// 두 측벽 사이를 잇는 바닥판 — 각 측 바닥판 안쪽 변을 그대로 잇는다.
|
||||
const slabBridge: OffsetPoint[] = [
|
||||
left.slabInner[0],
|
||||
right.slabInner[0],
|
||||
right.slabInner[1],
|
||||
left.slabInner[1],
|
||||
];
|
||||
const slabLengthM = Math.abs(
|
||||
Math.max(...sides.map((side) => side.parts[1].points[1].offset)) -
|
||||
Math.min(...sides.map((side) => side.parts[1].points[1].offset)),
|
||||
);
|
||||
|
||||
// 관 — 각 측 바닥판 상면(벽 바깥면)에 안힌다. 양측 표고가 다르면 그만큼 기운다.
|
||||
const pipeWall = pipeWallThicknessM(ford.pipe_kind, ford.diameter_m);
|
||||
const leftInvert = left.pipeEnd;
|
||||
const rightInvert = right.pipeEnd;
|
||||
const rise = (point: OffsetPoint, dz: number): OffsetPoint => ({
|
||||
offset: point.offset,
|
||||
elevation: point.elevation + dz,
|
||||
});
|
||||
const pipe = {
|
||||
outer: [
|
||||
rise(leftInvert, ford.diameter_m),
|
||||
rise(rightInvert, ford.diameter_m),
|
||||
rightInvert,
|
||||
leftInvert,
|
||||
],
|
||||
inner: [
|
||||
rise(leftInvert, ford.diameter_m - pipeWall),
|
||||
rise(rightInvert, ford.diameter_m - pipeWall),
|
||||
rise(rightInvert, pipeWall),
|
||||
rise(leftInvert, pipeWall),
|
||||
],
|
||||
};
|
||||
|
||||
// 성토(노체) 채움 — 두 측벽 도로측 면 사이, 관 윗면 ~ 노면.
|
||||
const faceAt = (side: FordSideLayout, elevation: number): number =>
|
||||
offsetAtElevation(side.roadFace[0], side.roadFace[1], elevation);
|
||||
const leftTopElev = rise(leftInvert, ford.diameter_m).elevation;
|
||||
const rightTopElev = rise(rightInvert, ford.diameter_m).elevation;
|
||||
const crown: OffsetPoint[] = [];
|
||||
const crownFrom = faceAt(right, rightTopElev);
|
||||
const crownTo = faceAt(left, leftTopElev);
|
||||
for (let offset = crownFrom; offset < crownTo; offset += 0.5) {
|
||||
crown.push({ offset, elevation: roadTopAt(offset) });
|
||||
}
|
||||
crown.push({ offset: crownTo, elevation: roadTopAt(crownTo) });
|
||||
const fill: OffsetPoint[] = [
|
||||
{ offset: crownFrom, elevation: rightTopElev },
|
||||
{ offset: crownTo, elevation: leftTopElev },
|
||||
...crown.slice().reverse(),
|
||||
];
|
||||
|
||||
const countText = ford.pipe_count > 1 ? ` ${ford.pipe_count}련` : "";
|
||||
return {
|
||||
ford,
|
||||
sides,
|
||||
slabBridge,
|
||||
slabLengthM,
|
||||
pipe,
|
||||
fill,
|
||||
label: {
|
||||
at: {
|
||||
offset: (leftInvert.offset + rightInvert.offset) / 2,
|
||||
elevation: (leftInvert.elevation + rightInvert.elevation) / 2 + ford.diameter_m / 2,
|
||||
},
|
||||
text: `${ford.pipe_kind ?? "관"} Ø${Math.round(ford.diameter_m * 1000)}${countText}`,
|
||||
},
|
||||
// 노견 밖 설계선은 측벽이 대신한다 — 벽 상단 도로측 꼭짓점에서 끊는다.
|
||||
designTrim: {
|
||||
minOffset: right.top.offset,
|
||||
maxOffset: left.top.offset,
|
||||
minElevation: right.top.elevation,
|
||||
maxElevation: left.top.elevation,
|
||||
},
|
||||
adjust: { inlet: inlet.adjust, outlet: outlet.adjust },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_Ford_Panel.ts
|
||||
* 세월교 측벽 **조정 오버레이 창**(2026-08-25 사용자 확정).
|
||||
*
|
||||
* 배수관 조정창(`_Cross_Structure_Panel.ts`)은 기슭막이 4축·다단·구간값·연동까지
|
||||
* 안고 있어 592줄이다 — 세월교는 조작 축이 다르고 그쪽에 얹으면 700줄을 넘긴다.
|
||||
* 그래서 창을 따로 두되 **CSS 클래스와 조작 관례(십자 D-pad·0.1m 눈금)는 같다**.
|
||||
*
|
||||
* 조작(사용자 확정): 측벽 높이 · 측벽 좌우 · 측벽 상하(1:1.2 대각) · 관경 · 관 수량.
|
||||
* 바닥판 연장은 날개벽 각도가 정하므로 여기서 직접 만지지 않는다(B05 옵션).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford_Geom";
|
||||
|
||||
/** 조정창이 쓰는 값·동작. 카드가 자기 상태에 맞춰 물려 준다. */
|
||||
export interface FordPanelDeps {
|
||||
/** 지금 조작값(적용된 결과 — 한계에 잘린 뒤 값). */
|
||||
adjustFor: (role: FordWallRole) => FordWallAdjust;
|
||||
/** 실제 그려진 벽 높이(m) — 자동일 때도 숫자를 보여 준다. */
|
||||
heightFor: (role: FordWallRole) => number;
|
||||
/** 높이 ±0.1m. */
|
||||
nudgeHeight: (role: FordWallRole, deltaM: number) => void;
|
||||
/** 좌우: 화면 좌(+)/우(−)로 미는 양(m). 부호 환산은 카드가 한다. */
|
||||
nudge: (role: FordWallRole, screenDeltaM: number) => void;
|
||||
/** 상하: 1:1.2 사면을 타는 대각 이동(수평 성분 m, + = 사면 아래). */
|
||||
nudgeSlope: (role: FordWallRole, deltaM: number) => void;
|
||||
/** 자동 자리로 되돌린다(세 축 모두). */
|
||||
reset: (role: FordWallRole) => void;
|
||||
/** 관경(mm)·수량(련) — 값은 B05 정본(`pipe_points`)에 되돌려 쓴다. */
|
||||
pipeDiameterMm: () => number;
|
||||
setPipeDiameterMm: (value: number) => void;
|
||||
pipeCount: () => number;
|
||||
setPipeCount: (value: number) => void;
|
||||
/** 바닥판 길이(m) — 날개벽 투영이 정한 값을 읽기 전용으로 보여 준다. */
|
||||
slabLengthM: () => number;
|
||||
/** 창을 닫는다 = 구조물 선택 해제. */
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface FordPanelHandle {
|
||||
root: HTMLElement;
|
||||
/** null이면 숨긴다. 값이 오면 그 측벽 기준으로 다시 그린다. */
|
||||
show: (role: FordWallRole | null) => void;
|
||||
}
|
||||
|
||||
/** 한 걸음 — 집수정 9키와 같은 0.1m 눈금. */
|
||||
const STEP_M = 0.1;
|
||||
/** 관경 선택지(mm) — B05 레지스트리 `ford_bridge.pipe_diameter_mm` choices와 같다. */
|
||||
const DIAMETER_CHOICES_MM = [800, 1000, 1200, 1500];
|
||||
|
||||
function makeButton(label: string, title: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "b06-structure-panel__btn";
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
button.addEventListener("click", (event) => {
|
||||
// 카드 선택·팬으로 번지면 도면이 다시 그려져 맞춰 둔 배율이 날아간다.
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/** 항목 행 — 1행 이름 라벨 + 2행 값 조작(배수관 조정창과 같은 2행 구조). */
|
||||
function makeRow(labelText: string): { row: HTMLElement; controls: HTMLElement } {
|
||||
const row = document.createElement("div");
|
||||
row.className = "b06-structure-panel__struct";
|
||||
const label = document.createElement("span");
|
||||
label.className = "b06-structure-panel__label";
|
||||
label.textContent = labelText;
|
||||
const controls = document.createElement("div");
|
||||
controls.className = "b06-structure-panel__controls";
|
||||
row.append(label, controls);
|
||||
return { row, controls };
|
||||
}
|
||||
|
||||
/** 세월교 측벽 조정 오버레이 창을 만든다. 처음에는 숨어 있다. */
|
||||
export function buildFordPanel(deps: FordPanelDeps): FordPanelHandle {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-structure-panel is-hidden";
|
||||
root.addEventListener("click", (event) => event.stopPropagation());
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "b06-structure-panel__title";
|
||||
|
||||
const value = document.createElement("div");
|
||||
value.className = "b06-structure-panel__value";
|
||||
const heightLine = document.createElement("span");
|
||||
const moveLine = document.createElement("span");
|
||||
const slabLine = document.createElement("span");
|
||||
value.append(heightLine, moveLine, slabLine);
|
||||
|
||||
let current: FordWallRole | null = null;
|
||||
const act = (run: (role: FordWallRole) => void) => () => {
|
||||
if (current) run(current);
|
||||
};
|
||||
|
||||
const heightRow = makeRow("측벽 높이");
|
||||
heightRow.controls.append(
|
||||
makeButton(
|
||||
"−",
|
||||
"측벽 높이 0.1m 낮추기",
|
||||
act((role) => deps.nudgeHeight(role, -STEP_M)),
|
||||
),
|
||||
makeButton(
|
||||
"+",
|
||||
"측벽 높이 0.1m 높이기",
|
||||
act((role) => deps.nudgeHeight(role, STEP_M)),
|
||||
),
|
||||
);
|
||||
|
||||
const moveRow = makeRow("이동");
|
||||
moveRow.controls.classList.add("b06-structure-panel__buttons");
|
||||
const dpad = (
|
||||
label: string,
|
||||
slot: "up" | "down" | "left" | "right" | "reset",
|
||||
tip: string,
|
||||
onClick: () => void,
|
||||
): HTMLButtonElement => {
|
||||
const button = makeButton(label, tip, onClick);
|
||||
button.classList.add(`b06-structure-panel__btn--${slot}`);
|
||||
return button;
|
||||
};
|
||||
moveRow.controls.append(
|
||||
dpad(
|
||||
"▲",
|
||||
"up",
|
||||
"사면 위로(1:1.2 대각)",
|
||||
act((role) => deps.nudgeSlope(role, -STEP_M)),
|
||||
),
|
||||
dpad(
|
||||
"◀",
|
||||
"left",
|
||||
"화면 왼쪽으로",
|
||||
act((role) => deps.nudge(role, STEP_M)),
|
||||
),
|
||||
dpad("↺", "reset", "이 측벽 조정값 초기화", act(deps.reset)),
|
||||
dpad(
|
||||
"▶",
|
||||
"right",
|
||||
"화면 오른쪽으로",
|
||||
act((role) => deps.nudge(role, -STEP_M)),
|
||||
),
|
||||
dpad(
|
||||
"▼",
|
||||
"down",
|
||||
"사면 아래로(1:1.2 대각)",
|
||||
act((role) => deps.nudgeSlope(role, STEP_M)),
|
||||
),
|
||||
);
|
||||
|
||||
const pipeRow = makeRow("관경 · 수량");
|
||||
const diameter = document.createElement("select");
|
||||
diameter.className = "b06-structure-panel__select";
|
||||
for (const mm of DIAMETER_CHOICES_MM) {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(mm);
|
||||
option.textContent = `Ø${mm}`;
|
||||
diameter.append(option);
|
||||
}
|
||||
diameter.addEventListener("change", () => deps.setPipeDiameterMm(Number(diameter.value)));
|
||||
const countValue = document.createElement("span");
|
||||
countValue.className = "b06-structure-panel__count";
|
||||
pipeRow.controls.append(
|
||||
diameter,
|
||||
makeButton("−", "관 수량 1련 줄이기", () =>
|
||||
deps.setPipeCount(Math.max(1, deps.pipeCount() - 1)),
|
||||
),
|
||||
countValue,
|
||||
makeButton("+", "관 수량 1련 늘리기", () => deps.setPipeCount(deps.pipeCount() + 1)),
|
||||
);
|
||||
|
||||
const closeButton = makeButton("✕", "닫기", () => deps.close());
|
||||
closeButton.classList.add("b06-structure-panel__close");
|
||||
|
||||
root.append(title, closeButton, value, heightRow.row, moveRow.row, pipeRow.row);
|
||||
|
||||
function render(): void {
|
||||
if (!current) return;
|
||||
const role = current === "inlet" ? "유입" : "유출";
|
||||
title.textContent = `세월교 ${role} 측벽`;
|
||||
const adjust: FordWallAdjust = deps.adjustFor(current);
|
||||
heightLine.textContent =
|
||||
`높이 ${deps.heightFor(current).toFixed(2)}m` + (adjust.heightM === null ? " (자동)" : "");
|
||||
moveLine.textContent = `좌우 ${adjust.lateralM.toFixed(1)}m · 상하 ${adjust.slopeM.toFixed(1)}m`;
|
||||
slabLine.textContent = `바닥판 ${deps.slabLengthM().toFixed(2)}m (날개벽 각도 종속)`;
|
||||
diameter.value = String(deps.pipeDiameterMm());
|
||||
countValue.textContent = `${deps.pipeCount()}련`;
|
||||
}
|
||||
|
||||
return {
|
||||
root,
|
||||
show(role) {
|
||||
current = role;
|
||||
root.classList.toggle("is-hidden", role === null);
|
||||
render();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 측점별 세월교 조작값 제어 — 페이지(세션·정본)가 구현한다. */
|
||||
export interface FordControl {
|
||||
adjustFor: (chainageM: number) => FordAdjust;
|
||||
update: (chainageM: number, role: FordWallRole, patch: Partial<FordWallAdjust>) => void;
|
||||
reset: (chainageM: number, role: FordWallRole) => void;
|
||||
/** 관경(mm)·수량(련) 저장 — B05 정본(`pipe_points`)으로 간다. */
|
||||
setPipe: (chainageM: number, patch: { pipe_diameter_mm?: number; pipe_count?: number }) => void;
|
||||
/** 지금 선택된 측벽(측점별). */
|
||||
selectedFor: (chainageM: number) => FordWallRole | null;
|
||||
select: (chainageM: number, role: FordWallRole | null) => void;
|
||||
}
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
type RockBoundaryControl,
|
||||
} from "./B06_Section_UI_Cross_Design";
|
||||
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { appendFordOverlay, computeFordLayout } from "./B06_Section_UI_Cross_Ford";
|
||||
import { buildFordPanel } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
import { fordPanelDeps } from "./B06_Section_UI_Cross_View_Ford";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
import type { FordHighlightSetter, FordWallRole } from "./B06_Section_UI_Cross_Ford";
|
||||
import { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type {
|
||||
@@ -34,13 +39,12 @@ import type {
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import { culvertCardState, structurePanelDeps } from "./B06_Section_UI_Cross_View_Structure";
|
||||
import { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import type { CrossWidthActions, ZoomPanState } from "./B06_Section_UI_Cross_View_Zoom";
|
||||
import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes";
|
||||
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
@@ -132,6 +136,8 @@ export function createCrossSectionCard(
|
||||
structureSpan?: StructureSpanControl,
|
||||
/** 연동·종단경사 반영 제어(2026-08-24). */
|
||||
revetLink?: RevetLinkControl,
|
||||
/** 세월교 측벽 조작 제어(2026-08-25). */
|
||||
ford?: FordControl,
|
||||
): CrossCardElement {
|
||||
// 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고
|
||||
// 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다
|
||||
@@ -171,9 +177,13 @@ export function createCrossSectionCard(
|
||||
let setBandActive: AreaHighlightSetter = () => undefined;
|
||||
let setChipActive: AreaHighlightSetter = () => undefined;
|
||||
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null;
|
||||
// 세월교 측벽 선택 — 배수관 기슭막이와 한 측점에 같이 서지 않는다.
|
||||
let activeFordWall = section.ford ? (ford?.selectedFor(section.chainage_m) ?? null) : null;
|
||||
let setFordActive: FordHighlightSetter = () => {};
|
||||
let showFordPanel = (_visible: boolean): void => {};
|
||||
let setRevetActive: RevetHighlightSetter = () => undefined;
|
||||
let showRevetControl: (visible: boolean) => void = () => undefined;
|
||||
/** 지금 그린 관 길이(m) — 조정창 표기용. 배수관 측점이 아니면 null. */
|
||||
/** 지금 그린 관 길이(m) — 조정창 표기용(배수관 측점 아니면 null). */
|
||||
let culvertPipeLengthM: number | null = null;
|
||||
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
|
||||
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
|
||||
@@ -182,8 +192,7 @@ export function createCrossSectionCard(
|
||||
let culvertExtraState = { canAdd: false, count: 0 };
|
||||
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
|
||||
let culvertBasinExtraState = { canAdd: false, count: 0 };
|
||||
/** 마지막 계산의 벽 제원(높이·재질) — 조정창 표시·높이 조작 기준.
|
||||
* 배관(유입·유출) 벽 높이는 **순수 높이**(바닥~상단 — 2026-08-23 사용자). */
|
||||
/** 마지막 계산의 벽 제원(높이·재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */
|
||||
let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
|
||||
/** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */
|
||||
let culvertAppliedD = new Map<RevetKey, number>();
|
||||
@@ -193,6 +202,28 @@ export function createCrossSectionCard(
|
||||
* 같이 선택**된다 — 이미 선택된 카드 안에서 다른 구조물을 고를 때는 선택이 옮겨만
|
||||
* 가고 카드 선택은 건드리지 않는다.
|
||||
*/
|
||||
/** 마지막 계산의 세월교 바닥판 길이(m)·측벽 높이(m) — 조정창 표시·조작 시작값. */
|
||||
let fordSlabLengthM = 0;
|
||||
let fordWallHeights = new Map<
|
||||
FordWallRole,
|
||||
number
|
||||
>(); /** 세월교 측벽을 고른다 — 기슭막이 선택과 같은 규칙(면적 강조와 배타). */
|
||||
const toggleFordWall = (role: FordWallRole): void => {
|
||||
const wasSelected = isSelected;
|
||||
activeFordWall = activeFordWall === role ? null : role;
|
||||
setFordActive(activeFordWall);
|
||||
showFordPanel(activeFordWall !== null);
|
||||
ford?.select(section.chainage_m, activeFordWall);
|
||||
if (activeFordWall !== null && activeArea !== null) {
|
||||
activeArea = null;
|
||||
setBandActive(null);
|
||||
setChipActive(null);
|
||||
}
|
||||
if (activeFordWall !== null) {
|
||||
if (wasSelected) onAreaSelect?.(section.station_id, null);
|
||||
else onSelect(section.station_id);
|
||||
}
|
||||
};
|
||||
const toggleRevet = (key: RevetKey): void => {
|
||||
const wasSelected = isSelected;
|
||||
activeRevet = activeRevet === key ? null : key;
|
||||
@@ -226,6 +257,12 @@ export function createCrossSectionCard(
|
||||
showRevetControl(false);
|
||||
revetOffset?.select(section.chainage_m, null);
|
||||
}
|
||||
if ((activeArea !== null || !nextSelected) && activeFordWall !== null) {
|
||||
activeFordWall = null;
|
||||
setFordActive(null);
|
||||
showFordPanel(false);
|
||||
ford?.select(section.chainage_m, null);
|
||||
}
|
||||
};
|
||||
const toggleArea = (key: CrossAreaKey): void => {
|
||||
if (!isSelected) {
|
||||
@@ -405,6 +442,12 @@ export function createCrossSectionCard(
|
||||
culvertLink,
|
||||
revetLink,
|
||||
);
|
||||
// 세월교 구체 기하 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25).
|
||||
const fordLayout = computeFordLayout(
|
||||
section,
|
||||
section.samples,
|
||||
ford?.adjustFor(section.chainage_m),
|
||||
);
|
||||
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
|
||||
appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
|
||||
appendCrossDesignOverlay(
|
||||
@@ -413,7 +456,7 @@ export function createCrossSectionCard(
|
||||
x,
|
||||
toDisplayY,
|
||||
drawSamples,
|
||||
culvertLayout?.designTrim ?? undefined,
|
||||
culvertLayout?.designTrim ?? fordLayout?.designTrim ?? undefined,
|
||||
);
|
||||
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
|
||||
if (rockBoundary && section.design.geometry_preset === "rock") {
|
||||
@@ -431,34 +474,24 @@ export function createCrossSectionCard(
|
||||
culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null);
|
||||
culvertInletIsBasin = !!culvertLayout?.basin;
|
||||
if (culvertLayout) {
|
||||
culvertInletOptions = culvertLayout.inletOptions;
|
||||
culvertExtraState = {
|
||||
canAdd: culvertLayout.outletFill.addable,
|
||||
count: culvertLayout.extraWalls.length,
|
||||
};
|
||||
culvertBasinExtraState = {
|
||||
canAdd: culvertLayout.basinFill.addable,
|
||||
count: culvertLayout.basinExtras.length,
|
||||
};
|
||||
culvertWallSpecs = new Map();
|
||||
const specOf = (wall: (typeof culvertLayout.walls)[number], key: RevetKey) =>
|
||||
culvertWallSpecs.set(key, {
|
||||
// 배관 벽은 순수 높이(바닥~상단 = 계산용 + 근입 0.5 — 2026-08-23 사용자).
|
||||
height: wall.role === "extra" ? wall.height : wall.height + REVET_EMBED_DEPTH_M,
|
||||
material: wall.material,
|
||||
});
|
||||
for (const wall of culvertLayout.walls) specOf(wall, wall.role as RevetKey);
|
||||
culvertLayout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey));
|
||||
culvertLayout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey));
|
||||
culvertAppliedD = new Map();
|
||||
culvertAppliedD.set("inlet", culvertLayout.revetShift.inlet.d ?? 0);
|
||||
culvertAppliedD.set("outlet", culvertLayout.revetShift.outlet.d ?? 0);
|
||||
culvertLayout.revetShift.extras.forEach((applied, i) =>
|
||||
culvertAppliedD.set(`extra${i}` as RevetKey, applied.d ?? 0),
|
||||
);
|
||||
culvertLayout.revetShift.basinExtras.forEach((applied, i) =>
|
||||
culvertAppliedD.set(`bextra${i}` as RevetKey, applied.d ?? 0),
|
||||
const state = culvertCardState(culvertLayout);
|
||||
culvertInletOptions = state.inletOptions;
|
||||
culvertExtraState = state.extraState;
|
||||
culvertBasinExtraState = state.basinExtraState;
|
||||
culvertWallSpecs = state.wallSpecs;
|
||||
culvertAppliedD = state.appliedD;
|
||||
}
|
||||
if (fordLayout) {
|
||||
setFordActive = appendFordOverlay(
|
||||
plotLayer,
|
||||
fordLayout,
|
||||
x,
|
||||
toDisplayY,
|
||||
ford ? toggleFordWall : undefined,
|
||||
);
|
||||
if (activeFordWall) setFordActive(activeFordWall);
|
||||
fordSlabLengthM = fordLayout.slabLengthM;
|
||||
fordWallHeights = new Map(fordLayout.sides.map((side) => [side.role, side.heightM]));
|
||||
}
|
||||
if (culvertLayout) {
|
||||
setRevetActive = appendCulvertOverlay(
|
||||
@@ -611,6 +644,25 @@ export function createCrossSectionCard(
|
||||
);
|
||||
showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
|
||||
showRevetControl(activeRevet !== null);
|
||||
// 세월교 조정창 — 배수관 창과 조작 축이 달라 따로 만든다(2026-08-25 사용자).
|
||||
const fordPanel =
|
||||
section.ford && ford
|
||||
? buildFordPanel(
|
||||
fordPanelDeps({
|
||||
section,
|
||||
ford,
|
||||
heightFor: (role) => fordWallHeights.get(role) ?? 0,
|
||||
slabLengthM: () => fordSlabLengthM,
|
||||
close: () => {
|
||||
if (activeFordWall) toggleFordWall(activeFordWall);
|
||||
},
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
if (fordPanel) {
|
||||
showFordPanel = (visible) => fordPanel.show(visible ? activeFordWall : null);
|
||||
showFordPanel(activeFordWall !== null);
|
||||
}
|
||||
// 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
|
||||
const widthActions: CrossWidthActions | undefined = stationWidth && {
|
||||
step: (deltaM) =>
|
||||
@@ -628,6 +680,7 @@ export function createCrossSectionCard(
|
||||
},
|
||||
};
|
||||
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root);
|
||||
if (fordPanel) chartWrap.append(fordPanel.root);
|
||||
if (activeArea) {
|
||||
setBandActive(activeArea);
|
||||
setChipActive(activeArea);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Cross_View_Ford.ts
|
||||
* 횡단 카드 ↔ **세월교 조정창** 배선 — 카드 렌더러(`_UI_Cross_View.ts`)에서 700줄
|
||||
* 제한으로 분리했다(2026-08-25). 조정창이 부르는 동작을 제어기로 넘기는 얇은 층이고,
|
||||
* 기하나 그리기는 하지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { FordPanelDeps, FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
import type { FordWallRole } from "./B06_Section_UI_Cross_Ford";
|
||||
|
||||
/** 조정창 배선에 필요한 카드 상태. 계산 결과는 그릴 때마다 바뀌므로 함수로 받는다. */
|
||||
export interface FordPanelContext {
|
||||
section: CrossSection;
|
||||
ford: FordControl;
|
||||
/** 마지막 계산의 측벽 높이(m) — 높이 조작의 시작값. */
|
||||
heightFor: (role: FordWallRole) => number;
|
||||
/** 마지막 계산의 바닥판 길이(m). */
|
||||
slabLengthM: () => number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** 화면 좌(◀) 방향을 벽 기준 바깥 부호로 환산한다. 유입 = 상단측. */
|
||||
function outwardOf(section: CrossSection, role: FordWallRole): number {
|
||||
return ((section.uphill_side ?? "left") === "left") === (role === "inlet") ? 1 : -1;
|
||||
}
|
||||
|
||||
export function fordPanelDeps(context: FordPanelContext): FordPanelDeps {
|
||||
const { section, ford } = context;
|
||||
const chainage = section.chainage_m;
|
||||
return {
|
||||
adjustFor: (role) => ford.adjustFor(chainage)[role],
|
||||
heightFor: context.heightFor,
|
||||
nudgeHeight: (role, deltaM) =>
|
||||
ford.update(chainage, role, { heightM: context.heightFor(role) + deltaM }),
|
||||
nudge: (role, screenDeltaM) =>
|
||||
ford.update(chainage, role, {
|
||||
lateralM: ford.adjustFor(chainage)[role].lateralM + screenDeltaM * outwardOf(section, role),
|
||||
}),
|
||||
nudgeSlope: (role, deltaM) =>
|
||||
ford.update(chainage, role, {
|
||||
slopeM: ford.adjustFor(chainage)[role].slopeM + deltaM,
|
||||
}),
|
||||
reset: (role) => ford.reset(chainage, role),
|
||||
pipeDiameterMm: () => Math.round((section.ford?.diameter_m ?? 1) * 1000),
|
||||
setPipeDiameterMm: (value) => ford.setPipe(chainage, { pipe_diameter_mm: value }),
|
||||
pipeCount: () => section.ford?.pipe_count ?? 1,
|
||||
setPipeCount: (value) => ford.setPipe(chainage, { pipe_count: value }),
|
||||
slabLengthM: context.slabLengthM,
|
||||
close: context.close,
|
||||
};
|
||||
}
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
import type { CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert";
|
||||
import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { CulvertLayout } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
@@ -178,3 +180,43 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
|
||||
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
|
||||
*/
|
||||
export function culvertCardState(layout: CulvertLayout): {
|
||||
inletOptions: CulvertLayout["inletOptions"];
|
||||
extraState: { canAdd: boolean; count: number };
|
||||
basinExtraState: { canAdd: boolean; count: number };
|
||||
wallSpecs: Map<RevetKey, { height: number; material: RevetMaterial }>;
|
||||
appliedD: Map<RevetKey, number>;
|
||||
} {
|
||||
const wallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
|
||||
const specOf = (wall: CulvertLayout["walls"][number], key: RevetKey): void => {
|
||||
wallSpecs.set(key, {
|
||||
// 배관 벽은 순수 높이(바닥~상단 = 계산용 + 근입 0.5 — 2026-08-23 사용자).
|
||||
height: wall.role === "extra" ? wall.height : wall.height + REVET_EMBED_DEPTH_M,
|
||||
material: wall.material,
|
||||
});
|
||||
};
|
||||
for (const wall of layout.walls) specOf(wall, wall.role as RevetKey);
|
||||
layout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey));
|
||||
layout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey));
|
||||
const appliedD = new Map<RevetKey, number>();
|
||||
appliedD.set("inlet", layout.revetShift.inlet.d ?? 0);
|
||||
appliedD.set("outlet", layout.revetShift.outlet.d ?? 0);
|
||||
layout.revetShift.extras.forEach((applied, i) =>
|
||||
appliedD.set(`extra${i}` as RevetKey, applied.d ?? 0),
|
||||
);
|
||||
layout.revetShift.basinExtras.forEach((applied, i) =>
|
||||
appliedD.set(`bextra${i}` as RevetKey, applied.d ?? 0),
|
||||
);
|
||||
return {
|
||||
inletOptions: layout.inletOptions,
|
||||
extraState: { canAdd: layout.outletFill.addable, count: layout.extraWalls.length },
|
||||
basinExtraState: { canAdd: layout.basinFill.addable, count: layout.basinExtras.length },
|
||||
wallSpecs,
|
||||
appliedD,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type StandardCrossSection,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
|
||||
import { buildCrossPatches } from "./B06_Section_UI_Page_Patches";
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import {
|
||||
@@ -431,6 +432,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
stationControls.extraWalls,
|
||||
stationControls.structureSpan,
|
||||
stationControls.revetLink,
|
||||
stationControls.ford,
|
||||
);
|
||||
|
||||
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
|
||||
@@ -509,42 +511,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
crossPatches: CrossSectionPatch[];
|
||||
massHaul: Record<string, unknown> | undefined;
|
||||
} {
|
||||
// 암 경계 오프셋 + 측점 개별 표시 반폭을 chainage 기준으로 합쳐 한 패치로 보낸다.
|
||||
const patchByChainage = new Map<number, CrossSectionPatch>();
|
||||
const patchFor = (chainageM: number): CrossSectionPatch => {
|
||||
const existing = patchByChainage.get(chainageM);
|
||||
if (existing) return existing;
|
||||
const created: CrossSectionPatch = { chainage_m: chainageM };
|
||||
patchByChainage.set(chainageM, created);
|
||||
return created;
|
||||
};
|
||||
rockOffsets.forEach((offset, chainage) => {
|
||||
patchFor(Number(chainage)).rock_boundary_offset_m = offset;
|
||||
const crossPatches = buildCrossPatches({
|
||||
rockOffsets,
|
||||
stationWidths,
|
||||
inletStructures,
|
||||
basinAdjustments,
|
||||
revetAdjusts: stationControls.revetAdjustsByChainage(),
|
||||
extraCounts: stationControls.extraCountsByChainage(),
|
||||
fordAdjusts: stationControls.fordAdjustsByChainage(),
|
||||
linkFlags: stationControls.linkFlagsByChainage(),
|
||||
});
|
||||
// 개별 표시 반폭(2026-08-06) — 확정·임시저장 시 design에 병합돼 재접근 시 유지된다.
|
||||
stationWidths.forEach((width, chainage) => {
|
||||
patchFor(Number(chainage)).display_half_width_m = width;
|
||||
});
|
||||
inletStructures.forEach((structure, chainage) => {
|
||||
patchFor(Number(chainage)).inlet_structure = structure;
|
||||
});
|
||||
basinAdjustments.forEach((adjust, chainage) => {
|
||||
patchFor(Number(chainage)).basin_adjust = adjust;
|
||||
});
|
||||
// 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 실어 확정한다(2026-08-24).
|
||||
stationControls.revetAdjustsByChainage().forEach((adjusts, chainage) => {
|
||||
patchFor(chainage).revet_adjust = adjusts;
|
||||
});
|
||||
stationControls.extraCountsByChainage().forEach((counts, chainage) => {
|
||||
patchFor(chainage).extra_wall_counts = counts;
|
||||
});
|
||||
// 연동 해제(측점별)·종단경사 반영(전체 공통) — 같은 체계로 정본에 싣는다.
|
||||
stationControls.linkFlagsByChainage().forEach((flags, chainage) => {
|
||||
if (flags.detached !== undefined) patchFor(chainage).revet_link_detached = flags.detached;
|
||||
if (flags.followGrade !== undefined)
|
||||
patchFor(chainage).revet_follow_grade = flags.followGrade;
|
||||
});
|
||||
const crossPatches: CrossSectionPatch[] = [...patchByChainage.values()];
|
||||
// 유토곡선은 화면 표시 내내 프론트 메모리에만 있다가 저장 시점에만 영구 저장된다.
|
||||
const result =
|
||||
sectionDetail && context?.earthwork_conversion
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Ford_Controls.ts
|
||||
* 세월교 측벽 조작값 제어 — 측점 제어기(`_UI_Page_Station_Controls.ts`)에서 700줄
|
||||
* 제한으로 분리했다(2026-08-25).
|
||||
*
|
||||
* 데이터 흐름은 집수정과 같다: 세션 사본 + 캐시 `design.ford_adjust`에 함께 실어
|
||||
* 3D(코리도)가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본(`pipe_points`)이
|
||||
* 원천이라 배수관 구간값과 같은 저장기로 되돌려 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch";
|
||||
import { DEFAULT_FORD_WALL_ADJUST } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordAdjust, FordWallAdjust, FordWallRole } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
|
||||
export interface FordControlDeps {
|
||||
/** 세션 보관 키(프로젝트·노선별). 없으면 세션에 담지 않는다. */
|
||||
sessionKey: () => string | null;
|
||||
sectionAt: (chainageM: number) => CrossSection | undefined;
|
||||
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
/** 관경·수량을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */
|
||||
queuePipeOptions: (chainageM: number, patch: Record<string, number>) => void;
|
||||
round1: (value: number) => number;
|
||||
clampMove: (value: number) => number;
|
||||
}
|
||||
|
||||
export interface FordControls {
|
||||
control: FordControl;
|
||||
/** 확정 payload용 — 측점별 조작값. */
|
||||
byChainage: () => Map<number, FordAdjust>;
|
||||
/** 노선이 바뀔 때 세션 값을 다시 읽는다. */
|
||||
load: () => void;
|
||||
}
|
||||
|
||||
export function createFordControls(deps: FordControlDeps): FordControls {
|
||||
const { round1, clampMove, sectionAt, patchCachedDesign } = deps;
|
||||
/* ── 세월교 측벽 조작(2026-08-25 사용자) ───────────────────────────────
|
||||
* 축은 집수정과 같다(높이·좌우·상하 대각). 값은 세션 사본 + 캐시 design에 함께
|
||||
* 실어 3D가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본이 원천이라
|
||||
* 배수관 구간값과 같은 저장기(`pipe_points` 되쓰기)로 보낸다. */
|
||||
const fordAdjustments = new Map<string, FordAdjust>();
|
||||
const fordSelections = new Map<string, FordWallRole | null>();
|
||||
const fordSessionKey = (): string | null => deps.sessionKey();
|
||||
|
||||
function loadFordAdjustments(): void {
|
||||
fordAdjustments.clear();
|
||||
const key = fordSessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
|
||||
string,
|
||||
FordAdjust
|
||||
>;
|
||||
Object.entries(parsed).forEach(([chainage, value]) =>
|
||||
fordAdjustments.set(chainage, {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.inlet },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.outlet },
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* 손상된 세션 값은 기본값으로 대체. */
|
||||
}
|
||||
}
|
||||
|
||||
function persistFordAdjustments(): void {
|
||||
const key = fordSessionKey();
|
||||
if (key)
|
||||
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments)));
|
||||
}
|
||||
|
||||
const fordAdjustAt = (chainageM: number): FordAdjust => {
|
||||
const stored = sectionAt(chainageM)?.design?.ford_adjust;
|
||||
return (
|
||||
fordAdjustments.get(chainageM.toFixed(2)) ?? {
|
||||
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
|
||||
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const writeFord = (chainageM: number, next: FordAdjust): void => {
|
||||
fordAdjustments.set(chainageM.toFixed(2), next);
|
||||
persistFordAdjustments();
|
||||
patchCachedDesign(chainageM, { ford_adjust: next });
|
||||
deps.refreshCard(chainageM);
|
||||
};
|
||||
|
||||
const fordControl: FordControl = {
|
||||
adjustFor: fordAdjustAt,
|
||||
update: (chainageM, role, patch) => {
|
||||
const current = fordAdjustAt(chainageM);
|
||||
const wall: FordWallAdjust = { ...current[role], ...patch };
|
||||
writeFord(chainageM, {
|
||||
...current,
|
||||
[role]: {
|
||||
// 높이는 0.1m 눈금, 하한(관경+토피)은 기하가 다시 잡는다.
|
||||
heightM: wall.heightM === null ? null : Math.max(round1(wall.heightM), 0),
|
||||
lateralM: Math.max(0, clampMove(wall.lateralM)),
|
||||
slopeM: clampMove(wall.slopeM),
|
||||
},
|
||||
});
|
||||
},
|
||||
reset: (chainageM, role) => {
|
||||
const current = fordAdjustAt(chainageM);
|
||||
writeFord(chainageM, { ...current, [role]: { ...DEFAULT_FORD_WALL_ADJUST } });
|
||||
},
|
||||
setPipe: (chainageM, patch) => {
|
||||
const spec = sectionAt(chainageM)?.ford;
|
||||
if (spec) {
|
||||
// 캐시를 먼저 고쳐 즉시 반영한다 — 저장은 늦게 묶어서 간다.
|
||||
if (patch.pipe_diameter_mm) spec.diameter_m = patch.pipe_diameter_mm / 1000;
|
||||
if (patch.pipe_count) spec.pipe_count = patch.pipe_count;
|
||||
}
|
||||
deps.queuePipeOptions(chainageM, patch);
|
||||
deps.refreshCard(chainageM);
|
||||
},
|
||||
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
|
||||
select: (chainageM, role) => {
|
||||
fordSelections.set(chainageM.toFixed(2), role);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
control: fordControl,
|
||||
byChainage: () => {
|
||||
const result = new Map<number, FordAdjust>();
|
||||
fordAdjustments.forEach((adjust, key) => {
|
||||
const value = Number(key);
|
||||
if (Number.isFinite(value)) result.set(value, adjust);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
load: loadFordAdjustments,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Page_Patches.ts
|
||||
* 확정·임시저장이 보내는 **측점별 편집분(cross_patches) 조립** — 페이지
|
||||
* (`_UI_Page.ts`)에서 700줄 제한으로 분리했다(2026-08-25).
|
||||
*
|
||||
* 세션·제어기에 흩어져 있던 조작값을 누가거리 하나로 합친다. 값은 만들지 않고
|
||||
* 모으기만 한다 — 판정·한계는 각 제어기가 이미 끝냈다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSectionPatch, StoredWallAdjust } from "./B06_Section_Api_Fetch";
|
||||
import type { BasinAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { InletStructureChoice } from "./B06_Section_UI_Cross_Culvert";
|
||||
|
||||
export interface CrossPatchSources {
|
||||
/** 암 경계선 오프셋(누가거리 문자열 키). */
|
||||
rockOffsets: Map<string, number>;
|
||||
/** 측점 개별 표시 반폭(m). */
|
||||
stationWidths: Map<string, number>;
|
||||
inletStructures: Map<string, InletStructureChoice>;
|
||||
basinAdjustments: Map<string, BasinAdjust>;
|
||||
revetAdjusts: Map<number, Record<string, StoredWallAdjust>>;
|
||||
extraCounts: Map<number, { outlet: number; basin: number }>;
|
||||
fordAdjusts: Map<number, FordAdjust>;
|
||||
linkFlags: Map<number, { detached?: boolean; followGrade?: boolean }>;
|
||||
}
|
||||
|
||||
/** 누가거리별 패치를 합쳐 한 배열로 돌려준다. */
|
||||
export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch[] {
|
||||
const patchByChainage = new Map<number, CrossSectionPatch>();
|
||||
const patchFor = (chainageM: number): CrossSectionPatch => {
|
||||
const existing = patchByChainage.get(chainageM);
|
||||
if (existing) return existing;
|
||||
const created: CrossSectionPatch = { chainage_m: chainageM };
|
||||
patchByChainage.set(chainageM, created);
|
||||
return created;
|
||||
};
|
||||
sources.rockOffsets.forEach((offset, chainage) => {
|
||||
patchFor(Number(chainage)).rock_boundary_offset_m = offset;
|
||||
});
|
||||
// 개별 표시 반폭(2026-08-06) — design에 병합돼 재접근 시 유지된다.
|
||||
sources.stationWidths.forEach((width, chainage) => {
|
||||
patchFor(Number(chainage)).display_half_width_m = width;
|
||||
});
|
||||
sources.inletStructures.forEach((structure, chainage) => {
|
||||
patchFor(Number(chainage)).inlet_structure = structure;
|
||||
});
|
||||
sources.basinAdjustments.forEach((adjust, chainage) => {
|
||||
patchFor(Number(chainage)).basin_adjust = adjust;
|
||||
});
|
||||
// 기슭막이 4축·다단 단 수 — 세션 전용이던 값을 정본에 실어 확정한다(2026-08-24).
|
||||
sources.revetAdjusts.forEach((adjusts, chainage) => {
|
||||
patchFor(chainage).revet_adjust = adjusts;
|
||||
});
|
||||
sources.extraCounts.forEach((counts, chainage) => {
|
||||
patchFor(chainage).extra_wall_counts = counts;
|
||||
});
|
||||
// 세월교 측벽 조작값(2026-08-25) — 배수관 값과 같은 자리에 실어 3D·재계산이 잇는다.
|
||||
sources.fordAdjusts.forEach((adjust, chainage) => {
|
||||
patchFor(chainage).ford_adjust = adjust;
|
||||
});
|
||||
// 연동 해제(측점별)·종단경사 반영(전체 공통) — 같은 체계로 정본에 싣는다.
|
||||
sources.linkFlags.forEach((flags, chainage) => {
|
||||
if (flags.detached !== undefined) patchFor(chainage).revet_link_detached = flags.detached;
|
||||
if (flags.followGrade !== undefined) patchFor(chainage).revet_follow_grade = flags.followGrade;
|
||||
});
|
||||
return [...patchByChainage.values()];
|
||||
}
|
||||
@@ -14,19 +14,27 @@ import type {
|
||||
InletStructureControl,
|
||||
RevetLinkControl,
|
||||
RevetOffsetControl,
|
||||
SpanRole,
|
||||
SpanValues,
|
||||
StationWidthControl,
|
||||
StructureSpanControl,
|
||||
} from "./B06_Section_UI_Cross_View";
|
||||
import { basinSpanOfSpec, revetSpanOfSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import * as CulvertConst from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import { culvertOwnerFor } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options";
|
||||
import { createFordControls } from "./B06_Section_UI_Page_Ford_Controls";
|
||||
import type { FordAdjust } from "./B06_Section_UI_Cross_Ford";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
|
||||
/** 제어가 페이지에서 가져다 쓰는 값들 — 클로저 대신 함수로 받아 결합을 끊는다. */
|
||||
export interface StationControlDeps {
|
||||
sessionKey: (
|
||||
kind: "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall" | "revetlink",
|
||||
kind:
|
||||
| "crossw"
|
||||
| "revetx"
|
||||
| "inletstruct"
|
||||
| "basinadjust"
|
||||
| "extrawall"
|
||||
| "revetlink"
|
||||
| "fordadjust",
|
||||
) => string | null;
|
||||
refreshCard: (chainageM: number) => void;
|
||||
detail: () => SectionDetailResponse | null;
|
||||
@@ -51,6 +59,10 @@ export interface StationControls {
|
||||
extraWalls: ExtraWallControl;
|
||||
structureSpan: StructureSpanControl;
|
||||
revetLink: RevetLinkControl;
|
||||
/** 세월교 측벽 조작(2026-08-25). */
|
||||
ford: FordControl;
|
||||
/** 확정 payload용 — 측점별 세월교 조작값. */
|
||||
fordAdjustsByChainage: () => Map<number, FordAdjust>;
|
||||
widths: Map<string, number>;
|
||||
inletStructures: Map<string, InletStructureChoice>;
|
||||
basinAdjustments: Map<string, BasinAdjust>;
|
||||
@@ -71,9 +83,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
/* ── 조작값 캐시 write-through(2026-08-24 사용자 확정 데이터 흐름) ────────
|
||||
* 영구저장소 → 브라우저 캐시 → 3D는 **캐시로 플롯**. 편집하면 캐시를 갱신하고 그
|
||||
* 캐시로 다시 그린다. 그래서 조정창 조작값은 세션에만 담지 않고 그 자리에서
|
||||
* `section.design`(B05·B06이 공유하는 같은 객체)에 실어야 한다 — 세션은 리로드
|
||||
* 복원용 사본이다. 코리도 해시가 design을 읽으므로 캐시가 바뀌면 3D가 다시 빌드된다.
|
||||
*/
|
||||
* `section.design`(B05·B06 공유 객체)에 실어야 한다 — 세션은 리로드 복원용 사본이고,
|
||||
* 코리도 해시가 design을 읽으므로 캐시가 바뀌면 3D가 다시 빌드된다. */
|
||||
const sectionAt = (chainageM: number): CrossSection | undefined =>
|
||||
deps
|
||||
.detail()
|
||||
@@ -231,7 +242,10 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
if (!spec) return;
|
||||
const reach = Math.max(
|
||||
...[spec.inlet, spec.outlet].flatMap((side) => {
|
||||
const span = side?.structure === "집수정" ? basinSpanOfSpec(side) : revetSpanOfSpec(side);
|
||||
const span =
|
||||
side?.structure === "집수정"
|
||||
? CulvertConst.basinSpanOfSpec(side)
|
||||
: CulvertConst.revetSpanOfSpec(side);
|
||||
return [span.beforeM, span.afterM];
|
||||
}),
|
||||
);
|
||||
@@ -511,46 +525,16 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
return culvertOwnerFor(section, sections) ?? null;
|
||||
};
|
||||
|
||||
/** 역할별 옵션 키 — 배수관 레지스트리 옵션 이름 그대로다. */
|
||||
const OPTION_KEYS: Record<SpanRole, { length: string; before: string; after: string }> = {
|
||||
inlet: {
|
||||
length: "inlet_revet_length_m",
|
||||
before: "inlet_revet_before_m",
|
||||
after: "inlet_revet_after_m",
|
||||
},
|
||||
outlet: {
|
||||
length: "outlet_revet_length_m",
|
||||
before: "outlet_revet_before_m",
|
||||
after: "outlet_revet_after_m",
|
||||
},
|
||||
basin: {
|
||||
length: "inlet_basin_length_m",
|
||||
before: "inlet_basin_before_m",
|
||||
after: "inlet_basin_after_m",
|
||||
},
|
||||
};
|
||||
|
||||
const spanValuesOf = (owner: CrossSection, role: SpanRole): SpanValues | null => {
|
||||
const spec = role === "outlet" ? owner.culvert?.outlet : owner.culvert?.inlet;
|
||||
if (!spec) return null;
|
||||
const span = role === "basin" ? basinSpanOfSpec(spec) : revetSpanOfSpec(spec);
|
||||
return {
|
||||
lengthM: Math.round((span.beforeM + span.afterM) * 10) / 10,
|
||||
beforeM: Math.round(span.beforeM * 10) / 10,
|
||||
afterM: Math.round(span.afterM * 10) / 10,
|
||||
};
|
||||
};
|
||||
|
||||
const structureSpanControl: StructureSpanControl = {
|
||||
ownerOf,
|
||||
valuesFor: (section, role) => {
|
||||
const owner = ownerOf(section);
|
||||
return owner ? spanValuesOf(owner, role) : null;
|
||||
return owner ? CulvertConst.spanValuesOf(owner, role) : null;
|
||||
},
|
||||
update: (section, role, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner?.culvert) return;
|
||||
const current = spanValuesOf(owner, role);
|
||||
const current = CulvertConst.spanValuesOf(owner, role);
|
||||
if (!current) return;
|
||||
// 길이를 바꾸면 **늘어난 몫만** 지금 비율대로 나눠 담는다 — 매번 총길이에서
|
||||
// 비율로 다시 계산하면 0.1m 반올림이 쌓여 5.0/5.0이 5.6/5.4로 어긋난다
|
||||
@@ -567,7 +551,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
if (patch.afterM !== undefined) afterM = Math.max(patch.afterM, 0);
|
||||
const lengthM = Math.round((beforeM + afterM) * 10) / 10;
|
||||
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
|
||||
const keys = OPTION_KEYS[role];
|
||||
const keys = CulvertConst.SPAN_OPTION_KEYS[role];
|
||||
if (role === "basin") {
|
||||
spec.basin_length_m = lengthM;
|
||||
spec.basin_before_m = beforeM;
|
||||
@@ -623,7 +607,9 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
? Math.max(
|
||||
...[spec.inlet, spec.outlet].flatMap((side) => {
|
||||
const span =
|
||||
side?.structure === "집수정" ? basinSpanOfSpec(side) : revetSpanOfSpec(side);
|
||||
side?.structure === "집수정"
|
||||
? CulvertConst.basinSpanOfSpec(side)
|
||||
: CulvertConst.revetSpanOfSpec(side);
|
||||
return [span.beforeM, span.afterM];
|
||||
}),
|
||||
)
|
||||
@@ -636,6 +622,17 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
},
|
||||
};
|
||||
|
||||
// 세월교 측벽 조작은 별도 모듈(700줄 제한) — 축·저장 흐름은 집수정과 같다.
|
||||
const fordControls = createFordControls({
|
||||
sessionKey: () => deps.sessionKey("fordadjust"),
|
||||
sectionAt,
|
||||
patchCachedDesign,
|
||||
refreshCard: deps.refreshCard,
|
||||
queuePipeOptions: culvertOptions.queue,
|
||||
round1,
|
||||
clampMove,
|
||||
});
|
||||
|
||||
return {
|
||||
stationWidth: stationWidthControl,
|
||||
revetOffset: revetOffsetControl,
|
||||
@@ -643,6 +640,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
extraWalls: extraWallControl,
|
||||
structureSpan: structureSpanControl,
|
||||
revetLink: revetLinkControl,
|
||||
ford: fordControls.control,
|
||||
fordAdjustsByChainage: fordControls.byChainage,
|
||||
widths: stationWidths,
|
||||
inletStructures,
|
||||
basinAdjustments,
|
||||
@@ -691,6 +690,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
loadBasinAdjustments();
|
||||
loadExtraCounts();
|
||||
loadLinkFlags();
|
||||
fordControls.load();
|
||||
},
|
||||
applyGlobalWidth: (requested, chainages) => {
|
||||
stationWidths.clear();
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
|
||||
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
|
||||
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
|
||||
import {
|
||||
createCrossSectionCard,
|
||||
type ExtraWallControl,
|
||||
@@ -125,10 +126,10 @@ export function createSectionView(
|
||||
inletStructure?: InletStructureControl,
|
||||
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
|
||||
extraWalls?: ExtraWallControl,
|
||||
/** 기슭막이·집수정 구간값(길이·전·후) 제어(2026-08-24). */
|
||||
/** 기슭막이·집수정 구간값(길이·전·후)·연동 제어(2026-08-24), 세월교 측벽(2026-08-25). */
|
||||
structureSpan?: StructureSpanControl,
|
||||
/** 연동·종단경사 반영 제어(2026-08-24). */
|
||||
revetLink?: RevetLinkControl,
|
||||
ford?: FordControl,
|
||||
): SectionViewController {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b06-section";
|
||||
@@ -405,6 +406,7 @@ export function createSectionView(
|
||||
culvertLinkFor(section),
|
||||
structureSpan,
|
||||
revetLink,
|
||||
ford,
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@@ -300,3 +300,19 @@
|
||||
color: var(--color-warning);
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
/* 세월교 구체(2026-08-25) — 바닥판은 콘크리트, 채움은 노체 성토. 측벽은 ㄴ형 집수정
|
||||
형상을 그대로 쓰므로 `.b06-chart__culvert-basin` 색을 공유한다. */
|
||||
.b06-chart__ford-slab {
|
||||
fill: color-mix(in srgb, var(--color-text-secondary) 30%, transparent);
|
||||
stroke: var(--color-text-secondary);
|
||||
stroke-width: 1.2;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.b06-chart__ford-fill {
|
||||
fill: color-mix(in srgb, var(--color-chart-0) 22%, transparent);
|
||||
stroke: var(--color-chart-0);
|
||||
stroke-width: 1;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user