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:
2026-08-25 12:05:42 +09:00
co-authored by Claude Opus 5
parent 0e2309f5ce
commit 81bb505cc4
24 changed files with 1504 additions and 136 deletions
@@ -344,6 +344,70 @@
"default": null, "default": null,
"required": true, "required": true,
"phase": "detail" "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 { ZERO_ADJUST } from "../B06_Section/B06_Section_UI_Cross_Culvert_Types";
import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert"; import { computeCulvertLayout } from "../B06_Section/B06_Section_UI_Cross_Culvert";
import type { CulvertLayout } 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·좌향 단위벡터와 종단 표고 오프셋. */ /** 스윕 프레임 하나 — 노선 위 한 지점의 중심 XY·좌향 단위벡터와 종단 표고 오프셋. */
export interface StructureFrame { 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 { export function culvertLayoutOf(section: CrossSection): CulvertLayout | null {
if (!section.culvert || !section.design) return null; if (!section.culvert || !section.design) return null;
const hash = structureHashParts(section).join("|"); const hash = structureHashParts(section).join("|");
@@ -156,7 +175,8 @@ export function buildCorridorStructures(
const solids: CorridorStructure[] = []; const solids: CorridorStructure[] = [];
for (const section of crossSections) { for (const section of crossSections) {
const layout = culvertLayoutOf(section); const layout = culvertLayoutOf(section);
if (!layout) continue; const fordLayout = fordLayoutOf(section);
if (!layout && !fordLayout) continue;
const chainage = section.chainage_m; const chainage = section.chainage_m;
const stationFrame: StructureFrame = { const stationFrame: StructureFrame = {
cx: section.center_x, cx: section.center_x,
@@ -193,10 +213,6 @@ export function buildCorridorStructures(
return rings; return rings;
}; };
const inletSpan = wallSpanOf(section, "inlet");
const outletSpan = wallSpanOf(section, "outlet");
const basinSpan = basinSpanOf(section);
const pushSwept = ( const pushSwept = (
kind: "revet" | "basin", kind: "revet" | "basin",
points: Array<{ offset: number; elevation: number }>, 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) { for (const wall of layout.walls) {
const span = wall.role === "inlet" ? inletSpan : outletSpan; const span = wall.role === "inlet" ? inletSpan : outletSpan;
pushSwept("revet", wall.points, span.beforeM, span.afterM); pushSwept("revet", wall.points, span.beforeM, span.afterM);
@@ -269,6 +322,24 @@ export function buildCorridorStructures(
/** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */ /** 해시 입력용 요약 — 구조물에 영향을 주는 값만 짧게 이어 붙인다(Corridor.ts가 쓴다). */
export function structureHashParts(section: CrossSection): Array<string | number> { 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; const culvert = section.culvert;
if (!culvert) return []; if (!culvert) return [];
const side = (spec: CulvertSideSpec): string => const side = (spec: CulvertSideSpec): string =>
@@ -340,15 +340,17 @@ export function createFacilityOptionsForm(
inletGroup.root.hidden = !isPipe; inletGroup.root.hidden = !isPipe;
outletGroup.root.hidden = !isPipe; outletGroup.root.hidden = !isPipe;
boxWrap.hidden = !isBox; boxWrap.hidden = !isBox;
wingInFields.root.hidden = !isBox; // 날개벽은 BOX암거와 세월교가 같은 옵션 한 벌을 쓴다(2026-08-25 사용자 확정).
wingOutFields.root.hidden = !isBox; const hasWing = isBox || current === "ford_bridge";
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge"; const isFord = current === "ford_pavement" || current === "ford_bridge";
fordRow.hidden = current !== "ford_bridge"; fordRow.hidden = current !== "ford_bridge";
fordWidthRow.hidden = !isFord; fordWidthRow.hidden = !isFord;
fordSummary.hidden = !isFord; fordSummary.hidden = !isFord;
if (isFord) syncFordSummary(); if (isFord) syncFordSummary();
if (isBox) { if (isBox) syncBoxSize();
syncBoxSize(); if (hasWing) {
wingInFields.syncVisibility(); wingInFields.syncVisibility();
wingOutFields.syncVisibility(); wingOutFields.syncVisibility();
} }
@@ -477,6 +479,8 @@ export function createFacilityOptionsForm(
if (Number.isFinite(count) && count > 0) options.pipe_count = count; if (Number.isFinite(count) && count > 0) options.pipe_count = count;
putNumber(options, "ford_width_m", fordWidth.value); putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options); putFordHeight(options);
wingInFields.read(options);
wingOutFields.read(options);
} }
return options; return options;
}, },
+44
View File
@@ -222,12 +222,40 @@ export interface CulvertSet {
outlet: CulvertSideSpec; 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 { export interface CrossSection extends SectionStation {
samples: SectionSample[]; samples: SectionSample[];
/** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */ /** DB에 저장된 잠정 설계 지정(있을 때만). 상세 조회 시 얹혀 온다. */
design?: CrossDesign; design?: CrossDesign;
/** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */ /** 배수관 측점의 세트 제원(있을 때만). `pipe_points.json` 정본 + 레지스트리 기본값. */
culvert?: CulvertSet; culvert?: CulvertSet;
/** 세월교 구체가 걸치는 측점의 세트 제원(있을 때만). 구체 폭 안이면 여러 측점에 붙는다. */
ford?: FordSet;
} }
export interface SectionDetailResponse { export interface SectionDetailResponse {
@@ -265,6 +293,19 @@ export interface StoredExtraWallCounts {
basin: number; basin: number;
} }
/** 세월교 측벽 한 매의 저장형 조작값(2026-08-25). */
export interface StoredFordWallAdjust {
heightM: number | null;
lateralM: number;
slopeM: number;
}
/** 세월교 측점의 저장형 조작값 — 유입·유출 측벽을 따로 담는다. */
export interface StoredFordAdjust {
inlet: StoredFordWallAdjust;
outlet: StoredFordWallAdjust;
}
/** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */ /** 측점 표준횡단 설계 계산 결과(잠정치). data.design에 저장되는 구조와 동일. */
export interface CrossDesign { export interface CrossDesign {
inlet_structure?: "auto" | "revet" | "I" | "L" | "U"; inlet_structure?: "auto" | "revet" | "I" | "L" | "U";
@@ -274,6 +315,8 @@ export interface CrossDesign {
lateralM: number; lateralM: number;
slopeM: number; slopeM: number;
}; };
/** 세월교 측벽 조작값(유입·유출) — 높이·좌우·상하(2026-08-25). */
ford_adjust?: StoredFordAdjust;
/** 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). /** 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
* 세션 전용이던 값을 정본에 남긴다(2026-08-24: 3D는 확정 결과물). */ * 세션 전용이던 값을 정본에 남긴다(2026-08-24: 3D는 확정 결과물). */
revet_adjust?: Record<string, StoredWallAdjust>; revet_adjust?: Record<string, StoredWallAdjust>;
@@ -382,6 +425,7 @@ export interface CrossSectionPatch {
slopeM: number; slopeM: number;
}; };
revet_adjust?: Record<string, StoredWallAdjust>; revet_adjust?: Record<string, StoredWallAdjust>;
ford_adjust?: StoredFordAdjust;
extra_wall_counts?: StoredExtraWallCounts; extra_wall_counts?: StoredExtraWallCounts;
/** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */
revet_link_detached?: boolean; revet_link_detached?: boolean;
+93 -12
View File
@@ -19,12 +19,17 @@ from __future__ import annotations
import json import json
import logging import logging
import math
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map 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 ( from config.config_system import (
DRAINAGE_CACHE_DIRNAME, DRAINAGE_CACHE_DIRNAME,
DRAINAGE_EDITS_DIRNAME, DRAINAGE_EDITS_DIRNAME,
@@ -56,6 +61,13 @@ REVET_FACE_SLOPE = 0.3
# 유입구 구조 선택지 — 레지스트리 `pipe.inlet_type` choices와 같은 문자열이다. # 유입구 구조 선택지 — 레지스트리 `pipe.inlet_type` choices와 같은 문자열이다.
INLET_STRUCTURE_BASIN = "집수정" 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: def pipe_points_file(project_root: Path) -> Path:
"""프로젝트 저장소 안의 관 지점 정본 경로.""" """프로젝트 저장소 안의 관 지점 정본 경로."""
@@ -68,17 +80,22 @@ def pipe_points_file(project_root: Path) -> Path:
) )
@lru_cache(maxsize=1) @lru_cache(maxsize=4)
def _registry_pipe_defaults() -> dict[str, Any]: def _registry_defaults(type_id: str) -> dict[str, Any]:
"""레지스트리 `pipe` 타입 옵션의 기본값 묶음. """레지스트리 타입 옵션의 기본값 묶음.
저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는 저장분에 없는 옵션을 채우는 유일한 출처다 — 여기서 꺼내 쓰면 B05 폼이 보여 주는
기본값과 항상 같은 값이 그림에 들어간다. 기본값과 항상 같은 값이 그림에 들어간다.
""" """
pipe_type = structure_type_map().get("pipe") structure_type = structure_type_map().get(type_id)
if pipe_type is None: if structure_type is None:
return {} 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: 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]]: 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 {} return {}
sets: dict[float, dict[str, Any]] = {} sets: dict[float, dict[str, Any]] = {}
for point in parse_pipe_points(document.get("points")): for point in parse_pipe_points(document.get("points")):
# BOX암거·물넘이포장·세월교는 그림이 다르다 — 이번 범위는 배관뿐이다. # BOX암거·물넘이포장 그림이 다르다 — 배관과 세월교만 그린다.
if point.facility != PIPE_FACILITY_PIPE: 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 continue
sets[round(float(point.chainage_m), 2)] = _culvert_set(point.options) sets[round(float(point.chainage_m), 2)] = spec
return sets return sets
@@ -208,8 +286,11 @@ def attach_culvert_sets(project_root: Path, cross_sections: list[dict[str, Any]]
if chainage is None: if chainage is None:
continue continue
for pipe_chainage, spec in sets.items(): 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 attached += 1
break break
return attached return attached
+1
View File
@@ -663,6 +663,7 @@ async def compute_cross_section_design(
"display_half_width_m", "display_half_width_m",
"inlet_structure", "inlet_structure",
"basin_adjust", "basin_adjust",
"ford_adjust",
"revet_adjust", "revet_adjust",
"extra_wall_counts", "extra_wall_counts",
"revet_link_detached", "revet_link_detached",
@@ -126,6 +126,8 @@ async def _apply_section_edits(
patch["revet_adjust"] = { patch["revet_adjust"] = {
role: adjust.model_dump() for role, adjust in patch_item.revet_adjust.items() 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: if patch_item.extra_wall_counts is not None:
patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump() patch["extra_wall_counts"] = patch_item.extra_wall_counts.model_dump()
if patch_item.revet_link_detached is not None: if patch_item.revet_link_detached is not None:
+2
View File
@@ -140,4 +140,6 @@ def recompute_designs_for_alignment(
design["inlet_structure"] = stored["inlet_structure"] design["inlet_structure"] = stored["inlet_structure"]
if stored.get("basin_adjust") is not None: if stored.get("basin_adjust") is not None:
design["basin_adjust"] = stored["basin_adjust"] design["basin_adjust"] = stored["basin_adjust"]
if stored.get("ford_adjust") is not None:
design["ford_adjust"] = stored["ford_adjust"]
section["design"] = design section["design"] = design
+17
View File
@@ -91,6 +91,21 @@ class ExtraWallCountsPatch(BaseModel):
basin: int = Field(default=0, ge=0, le=9) 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): class CrossSectionPatch(BaseModel):
"""확정 시 측점별 data.design에 병합할 프론트 세션 보관값.""" """확정 시 측점별 data.design에 병합할 프론트 세션 보관값."""
@@ -104,6 +119,8 @@ class CrossSectionPatch(BaseModel):
# 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…). # 기슭막이 4축 조작값 — 키는 역할("inlet"/"outlet"/"extra0"…/"bextra0"…).
# 세션 전용이던 값을 정본으로 올린다(2026-08-24 사용자: 3D는 확정 결과물). # 세션 전용이던 값을 정본으로 올린다(2026-08-24 사용자: 3D는 확정 결과물).
revet_adjust: dict[str, WallAdjustPatch] | None = None revet_adjust: dict[str, WallAdjustPatch] | None = None
# 세월교 측벽 조작값(유입·유출) — 2026-08-25 사용자.
ford_adjust: FordAdjustPatch | None = None
extra_wall_counts: ExtraWallCountsPatch | None = None extra_wall_counts: ExtraWallCountsPatch | None = None
# 연동 기슭막이 옵션(2026-08-24 사용자). 연동 해제는 측점별, 종단경사 반영은 # 연동 기슭막이 옵션(2026-08-24 사용자). 연동 해제는 측점별, 종단경사 반영은
# 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다. # 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다.
+1
View File
@@ -102,6 +102,7 @@ export function crossPatchesFromCache(detail: SectionDetailResponse): CrossSecti
put("inlet_structure", design.inlet_structure); put("inlet_structure", design.inlet_structure);
put("basin_adjust", design.basin_adjust); put("basin_adjust", design.basin_adjust);
put("revet_adjust", design.revet_adjust); put("revet_adjust", design.revet_adjust);
put("ford_adjust", design.ford_adjust);
put("extra_wall_counts", design.extra_wall_counts); put("extra_wall_counts", design.extra_wall_counts);
put("revet_link_detached", design.revet_link_detached); put("revet_link_detached", design.revet_link_detached);
put("revet_follow_grade", design.revet_follow_grade); put("revet_follow_grade", design.revet_follow_grade);
@@ -84,6 +84,10 @@ export interface BasinBuildInput {
/** 표준단면 절토 경사(1:n) — 절토 계획선용. */ /** 표준단면 절토 경사(1:n) — 절토 계획선용. */
cutSlopeRatio: number; cutSlopeRatio: number;
adjust: BasinAdjust; 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, elevation: baseAnchor.elevation - adjust.slopeM / 1.2,
}; };
const anchor = movedAnchor; const anchor = movedAnchor;
const memberT = BASIN_MEMBER_THICKNESS_M; const memberT = input.memberThicknessM ?? BASIN_MEMBER_THICKNESS_M;
const floorThickness = memberT; const floorThickness = input.floorThicknessM ?? memberT;
// 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값). // 벽 높이: I형은 노견 접점까지 성장, ㄴ·ㄷ형은 내부 높이 1.2m 고정(임시값).
const wallHeight = const wallHeight =
shape === "I" shape === "I"
@@ -4,7 +4,8 @@
* (700 ). DB . * (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`). /** (m) ( `B06_Section_Engine_Culvert.MIN_PIPE_COVER_M`).
* DB에 2 · "복토 50㎝ 이상" * 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); const length = Math.max(spec?.basin_length_m ?? 2, 0.1);
return spanFrom(length, spec?.basin_before_m, spec?.basin_after_m); 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,
};
};
+174
View File
@@ -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}${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;
}
+86 -33
View File
@@ -24,6 +24,11 @@ import {
type RockBoundaryControl, type RockBoundaryControl,
} from "./B06_Section_UI_Cross_Design"; } from "./B06_Section_UI_Cross_Design";
import { appendCulvertOverlay } from "./B06_Section_UI_Cross_Culvert"; 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 { computeCardCulvert, culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire"; import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { import type {
@@ -34,13 +39,12 @@ import type {
StructureSpanControl, StructureSpanControl,
} from "./B06_Section_UI_Cross_Culvert_Wire"; } from "./B06_Section_UI_Cross_Culvert_Wire";
import { buildStructurePanel } from "./B06_Section_UI_Cross_Structure_Panel"; 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 { attachZoomPan, buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import type { CrossWidthActions, ZoomPanState } 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 { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes"; import { createCrossAxes, IDENTITY_VIEW } from "./B06_Section_UI_Cross_Axes";
import type { RevetHighlightSetter, RevetKey } from "./B06_Section_UI_Cross_Culvert"; 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 { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types"; import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import { import {
@@ -132,6 +136,8 @@ export function createCrossSectionCard(
structureSpan?: StructureSpanControl, structureSpan?: StructureSpanControl,
/** 연동·종단경사 반영 제어(2026-08-24). */ /** 연동·종단경사 반영 제어(2026-08-24). */
revetLink?: RevetLinkControl, revetLink?: RevetLinkControl,
/** 세월교 측벽 조작 제어(2026-08-25). */
ford?: FordControl,
): CrossCardElement { ): CrossCardElement {
// 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고 // 링크 카드 = 구조물이 옆 측점에 서 있고 그 연장이 여기까지 온 경우. 관만 숨기고
// 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다 // 선택·조정은 연다 — 연동을 풀어 이 측점 위치를 따로 잡을 수 있어야 한다
@@ -171,9 +177,13 @@ export function createCrossSectionCard(
let setBandActive: AreaHighlightSetter = () => undefined; let setBandActive: AreaHighlightSetter = () => undefined;
let setChipActive: AreaHighlightSetter = () => undefined; let setChipActive: AreaHighlightSetter = () => undefined;
let activeRevet: RevetKey | null = revetOffset?.selectedFor(section) ?? null; 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 setRevetActive: RevetHighlightSetter = () => undefined;
let showRevetControl: (visible: boolean) => void = () => undefined; let showRevetControl: (visible: boolean) => void = () => undefined;
/** 지금 그린 관 길이(m) — 조정창 표기용. 배수관 측점 아니면 null. */ /** 지금 그린 관 길이(m) — 조정창 표기용(배수관 측점 아니면 null). */
let culvertPipeLengthM: number | null = null; let culvertPipeLengthM: number | null = null;
let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기 let culvertInletIsBasin = false; // 유입이 집수정인가 — 조정창 표시·제목 분기
/** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */ /** 드롭다운 선택지 가용성(2026-08-22) — 기하 판정을 조정창에 전달. */
@@ -182,8 +192,7 @@ export function createCrossSectionCard(
let culvertExtraState = { canAdd: false, count: 0 }; let culvertExtraState = { canAdd: false, count: 0 };
/** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */ /** 집수정 계류측 다단 상태(2026-08-22) — 조정창의 집수정 단 수 행이 쓴다. */
let culvertBasinExtraState = { canAdd: false, count: 0 }; let culvertBasinExtraState = { canAdd: false, count: 0 };
/** (·) · . /** 마지막 계산의 벽 제원(높이·재질) — 배관 벽은 순수 높이(2026-08-23 사용자). */
* (·) ** **(~ 2026-08-23 ). */
let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>(); let culvertWallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
/** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */
let culvertAppliedD = new Map<RevetKey, number>(); 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 toggleRevet = (key: RevetKey): void => {
const wasSelected = isSelected; const wasSelected = isSelected;
activeRevet = activeRevet === key ? null : key; activeRevet = activeRevet === key ? null : key;
@@ -226,6 +257,12 @@ export function createCrossSectionCard(
showRevetControl(false); showRevetControl(false);
revetOffset?.select(section.chainage_m, null); 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 => { const toggleArea = (key: CrossAreaKey): void => {
if (!isSelected) { if (!isSelected) {
@@ -405,6 +442,12 @@ export function createCrossSectionCard(
culvertLink, culvertLink,
revetLink, revetLink,
); );
// 세월교 구체 기하 — 배수관과 그림이 달라 계산·그리기를 따로 탄다(2026-08-25).
const fordLayout = computeFordLayout(
section,
section.samples,
ford?.adjustFor(section.chainage_m),
);
// 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다. // 포장층 → 설계선 → 암 경계선 순으로 겹쳐, 설계선이 포장 박스 위에 오게 한다.
appendPavementOverlay(plotLayer, section.design, x, toDisplayY); appendPavementOverlay(plotLayer, section.design, x, toDisplayY);
appendCrossDesignOverlay( appendCrossDesignOverlay(
@@ -413,7 +456,7 @@ export function createCrossSectionCard(
x, x,
toDisplayY, toDisplayY,
drawSamples, drawSamples,
culvertLayout?.designTrim ?? undefined, culvertLayout?.designTrim ?? fordLayout?.designTrim ?? undefined,
); );
// 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의. // 암 경계선은 지면선(지반선) 복사 + 오프셋 — 계획선 기준이 아님에 유의.
if (rockBoundary && section.design.geometry_preset === "rock") { if (rockBoundary && section.design.geometry_preset === "rock") {
@@ -431,34 +474,24 @@ export function createCrossSectionCard(
culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null); culvertPipeLengthM = isLinkedCulvert ? null : (culvertLayout?.pipe.lengthM ?? null);
culvertInletIsBasin = !!culvertLayout?.basin; culvertInletIsBasin = !!culvertLayout?.basin;
if (culvertLayout) { if (culvertLayout) {
culvertInletOptions = culvertLayout.inletOptions; const state = culvertCardState(culvertLayout);
culvertExtraState = { culvertInletOptions = state.inletOptions;
canAdd: culvertLayout.outletFill.addable, culvertExtraState = state.extraState;
count: culvertLayout.extraWalls.length, culvertBasinExtraState = state.basinExtraState;
}; culvertWallSpecs = state.wallSpecs;
culvertBasinExtraState = { culvertAppliedD = state.appliedD;
canAdd: culvertLayout.basinFill.addable, }
count: culvertLayout.basinExtras.length, if (fordLayout) {
}; setFordActive = appendFordOverlay(
culvertWallSpecs = new Map(); plotLayer,
const specOf = (wall: (typeof culvertLayout.walls)[number], key: RevetKey) => fordLayout,
culvertWallSpecs.set(key, { x,
// 배관 벽은 순수 높이(바닥~상단 = 계산용 + 근입 0.5 — 2026-08-23 사용자). toDisplayY,
height: wall.role === "extra" ? wall.height : wall.height + REVET_EMBED_DEPTH_M, ford ? toggleFordWall : undefined,
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),
); );
if (activeFordWall) setFordActive(activeFordWall);
fordSlabLengthM = fordLayout.slabLengthM;
fordWallHeights = new Map(fordLayout.sides.map((side) => [side.role, side.heightM]));
} }
if (culvertLayout) { if (culvertLayout) {
setRevetActive = appendCulvertOverlay( setRevetActive = appendCulvertOverlay(
@@ -611,6 +644,25 @@ export function createCrossSectionCard(
); );
showRevetControl = (visible) => panel.show(visible ? activeRevet : null); showRevetControl = (visible) => panel.show(visible ? activeRevet : null);
showRevetControl(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). // 우측 상단 줌 버튼이 원배율에서 표시 반폭까지 다룬다(하단 ◀/▶/↺ 폐지, 2026-08-23).
const widthActions: CrossWidthActions | undefined = stationWidth && { const widthActions: CrossWidthActions | undefined = stationWidth && {
step: (deltaM) => step: (deltaM) =>
@@ -628,6 +680,7 @@ export function createCrossSectionCard(
}, },
}; };
chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root); chartWrap.append(svg, readout.root, buildZoomControls(zoomPan, widthActions), panel.root);
if (fordPanel) chartWrap.append(fordPanel.root);
if (activeArea) { if (activeArea) {
setBandActive(activeArea); setBandActive(activeArea);
setChipActive(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 { CrossSection } from "./B06_Section_Api_Fetch";
import type { RevetKey } from "./B06_Section_UI_Cross_Culvert"; 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 { 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 { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel"; import type { StructurePanelDeps } from "./B06_Section_UI_Cross_Structure_Panel";
import { showToast } from "@ui/ui_template_elements"; 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,
};
}
+11 -35
View File
@@ -30,6 +30,7 @@ import {
type StandardCrossSection, type StandardCrossSection,
} from "./B06_Section_Api_Fetch"; } from "./B06_Section_Api_Fetch";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; 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 { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { import {
@@ -431,6 +432,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
stationControls.extraWalls, stationControls.extraWalls,
stationControls.structureSpan, stationControls.structureSpan,
stationControls.revetLink, stationControls.revetLink,
stationControls.ford,
); );
// 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다.
@@ -509,42 +511,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
crossPatches: CrossSectionPatch[]; crossPatches: CrossSectionPatch[];
massHaul: Record<string, unknown> | undefined; massHaul: Record<string, unknown> | undefined;
} { } {
// 암 경계 오프셋 + 측점 개별 표시 반폭을 chainage 기준으로 합쳐 한 패치로 보낸다. const crossPatches = buildCrossPatches({
const patchByChainage = new Map<number, CrossSectionPatch>(); rockOffsets,
const patchFor = (chainageM: number): CrossSectionPatch => { stationWidths,
const existing = patchByChainage.get(chainageM); inletStructures,
if (existing) return existing; basinAdjustments,
const created: CrossSectionPatch = { chainage_m: chainageM }; revetAdjusts: stationControls.revetAdjustsByChainage(),
patchByChainage.set(chainageM, created); extraCounts: stationControls.extraCountsByChainage(),
return created; fordAdjusts: stationControls.fordAdjustsByChainage(),
}; linkFlags: stationControls.linkFlagsByChainage(),
rockOffsets.forEach((offset, chainage) => {
patchFor(Number(chainage)).rock_boundary_offset_m = offset;
}); });
// 개별 표시 반폭(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 = const result =
sectionDetail && context?.earthwork_conversion 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, InletStructureControl,
RevetLinkControl, RevetLinkControl,
RevetOffsetControl, RevetOffsetControl,
SpanRole,
SpanValues,
StationWidthControl, StationWidthControl,
StructureSpanControl, StructureSpanControl,
} from "./B06_Section_UI_Cross_View"; } 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 { culvertOwnerFor } from "./B06_Section_UI_Cross_Culvert_Wire";
import { createCulvertOptionWriter } from "./B06_Section_Api_Culvert_Options"; 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 { export interface StationControlDeps {
sessionKey: ( sessionKey: (
kind: "crossw" | "revetx" | "inletstruct" | "basinadjust" | "extrawall" | "revetlink", kind:
| "crossw"
| "revetx"
| "inletstruct"
| "basinadjust"
| "extrawall"
| "revetlink"
| "fordadjust",
) => string | null; ) => string | null;
refreshCard: (chainageM: number) => void; refreshCard: (chainageM: number) => void;
detail: () => SectionDetailResponse | null; detail: () => SectionDetailResponse | null;
@@ -51,6 +59,10 @@ export interface StationControls {
extraWalls: ExtraWallControl; extraWalls: ExtraWallControl;
structureSpan: StructureSpanControl; structureSpan: StructureSpanControl;
revetLink: RevetLinkControl; revetLink: RevetLinkControl;
/** 세월교 측벽 조작(2026-08-25). */
ford: FordControl;
/** 확정 payload용 — 측점별 세월교 조작값. */
fordAdjustsByChainage: () => Map<number, FordAdjust>;
widths: Map<string, number>; widths: Map<string, number>;
inletStructures: Map<string, InletStructureChoice>; inletStructures: Map<string, InletStructureChoice>;
basinAdjustments: Map<string, BasinAdjust>; basinAdjustments: Map<string, BasinAdjust>;
@@ -71,9 +83,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
/* write-through(2026-08-24 ) /* write-through(2026-08-24 )
* 3D는 ** **. * 3D는 ** **.
* . * .
* `section.design`(B05·B06이 ) * `section.design`(B05·B06 ) ,
* . design을 3D가 . * design을 3D가 . */
*/
const sectionAt = (chainageM: number): CrossSection | undefined => const sectionAt = (chainageM: number): CrossSection | undefined =>
deps deps
.detail() .detail()
@@ -231,7 +242,10 @@ export function createStationControls(deps: StationControlDeps): StationControls
if (!spec) return; if (!spec) return;
const reach = Math.max( const reach = Math.max(
...[spec.inlet, spec.outlet].flatMap((side) => { ...[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]; return [span.beforeM, span.afterM];
}), }),
); );
@@ -511,46 +525,16 @@ export function createStationControls(deps: StationControlDeps): StationControls
return culvertOwnerFor(section, sections) ?? null; 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 = { const structureSpanControl: StructureSpanControl = {
ownerOf, ownerOf,
valuesFor: (section, role) => { valuesFor: (section, role) => {
const owner = ownerOf(section); const owner = ownerOf(section);
return owner ? spanValuesOf(owner, role) : null; return owner ? CulvertConst.spanValuesOf(owner, role) : null;
}, },
update: (section, role, patch) => { update: (section, role, patch) => {
const owner = ownerOf(section); const owner = ownerOf(section);
if (!owner?.culvert) return; if (!owner?.culvert) return;
const current = spanValuesOf(owner, role); const current = CulvertConst.spanValuesOf(owner, role);
if (!current) return; if (!current) return;
// 길이를 바꾸면 **늘어난 몫만** 지금 비율대로 나눠 담는다 — 매번 총길이에서 // 길이를 바꾸면 **늘어난 몫만** 지금 비율대로 나눠 담는다 — 매번 총길이에서
// 비율로 다시 계산하면 0.1m 반올림이 쌓여 5.0/5.0이 5.6/5.4로 어긋난다 // 비율로 다시 계산하면 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); if (patch.afterM !== undefined) afterM = Math.max(patch.afterM, 0);
const lengthM = Math.round((beforeM + afterM) * 10) / 10; const lengthM = Math.round((beforeM + afterM) * 10) / 10;
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet; 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") { if (role === "basin") {
spec.basin_length_m = lengthM; spec.basin_length_m = lengthM;
spec.basin_before_m = beforeM; spec.basin_before_m = beforeM;
@@ -623,7 +607,9 @@ export function createStationControls(deps: StationControlDeps): StationControls
? Math.max( ? Math.max(
...[spec.inlet, spec.outlet].flatMap((side) => { ...[spec.inlet, spec.outlet].flatMap((side) => {
const span = const span =
side?.structure === "집수정" ? basinSpanOfSpec(side) : revetSpanOfSpec(side); side?.structure === "집수정"
? CulvertConst.basinSpanOfSpec(side)
: CulvertConst.revetSpanOfSpec(side);
return [span.beforeM, span.afterM]; 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 { return {
stationWidth: stationWidthControl, stationWidth: stationWidthControl,
revetOffset: revetOffsetControl, revetOffset: revetOffsetControl,
@@ -643,6 +640,8 @@ export function createStationControls(deps: StationControlDeps): StationControls
extraWalls: extraWallControl, extraWalls: extraWallControl,
structureSpan: structureSpanControl, structureSpan: structureSpanControl,
revetLink: revetLinkControl, revetLink: revetLinkControl,
ford: fordControls.control,
fordAdjustsByChainage: fordControls.byChainage,
widths: stationWidths, widths: stationWidths,
inletStructures, inletStructures,
basinAdjustments, basinAdjustments,
@@ -691,6 +690,7 @@ export function createStationControls(deps: StationControlDeps): StationControls
loadBasinAdjustments(); loadBasinAdjustments();
loadExtraCounts(); loadExtraCounts();
loadLinkFlags(); loadLinkFlags();
fordControls.load();
}, },
applyGlobalWidth: (requested, chainages) => { applyGlobalWidth: (requested, chainages) => {
stationWidths.clear(); stationWidths.clear();
+4 -2
View File
@@ -25,6 +25,7 @@ import type {
} from "./B06_Section_Api_Fetch"; } from "./B06_Section_Api_Fetch";
import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design"; import type { RockBoundaryControl } from "./B06_Section_UI_Cross_Design";
import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas"; import type { CrossAreaKey } from "./B06_Section_UI_Cross_Areas";
import type { FordControl } from "./B06_Section_UI_Cross_Ford_Panel";
import { import {
createCrossSectionCard, createCrossSectionCard,
type ExtraWallControl, type ExtraWallControl,
@@ -125,10 +126,10 @@ export function createSectionView(
inletStructure?: InletStructureControl, inletStructure?: InletStructureControl,
/** 유출측 추가 기슭막이 개수 제어(2026-08-22). */ /** 유출측 추가 기슭막이 개수 제어(2026-08-22). */
extraWalls?: ExtraWallControl, extraWalls?: ExtraWallControl,
/** 기슭막이·집수정 구간값(길이·전·후) 제어(2026-08-24). */ /** 기슭막이·집수정 구간값(길이·전·후)·연동 제어(2026-08-24), 세월교 측벽(2026-08-25). */
structureSpan?: StructureSpanControl, structureSpan?: StructureSpanControl,
/** 연동·종단경사 반영 제어(2026-08-24). */
revetLink?: RevetLinkControl, revetLink?: RevetLinkControl,
ford?: FordControl,
): SectionViewController { ): SectionViewController {
const root = document.createElement("div"); const root = document.createElement("div");
root.className = "b06-section"; root.className = "b06-section";
@@ -405,6 +406,7 @@ export function createSectionView(
culvertLinkFor(section), culvertLinkFor(section),
structureSpan, structureSpan,
revetLink, revetLink,
ford,
); );
/** /**
@@ -300,3 +300,19 @@
color: var(--color-warning); color: var(--color-warning);
cursor: help; 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;
}