From 81bb505cc42af55c66a88fabd856c4f771684f90 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 25 Aug 2026 12:05:42 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=C2=B7B06):=20=EC=84=B8=EC=9B=94?= =?UTF-8?q?=EA=B5=90=20=ED=9A=A1=EB=8B=A8=20=EA=B5=AC=EC=B2=B4=C2=B7?= =?UTF-8?q?=EC=A1=B0=EC=A0=95=EC=B0=BD=C2=B73D=20=EC=98=88=EC=83=81?= =?UTF-8?q?=ED=98=95=EC=83=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- B05_Profile/B05_Profile_Structure_Types.json | 64 ++++ .../B05_Profile_UI_Corridor_Structures.ts | 81 ++++- .../B05_Profile_UI_Drainage_Facility.ts | 12 +- B06_Section/B06_Section_Api_Fetch.ts | 44 +++ B06_Section/B06_Section_Engine_Culvert.py | 105 +++++- B06_Section/B06_Section_Router.py | 1 + B06_Section/B06_Section_Router_Confirm.py | 2 + B06_Section/B06_Section_Router_Design.py | 2 + B06_Section/B06_Section_Schema.py | 17 + B06_Section/B06_Section_Section_Store.ts | 1 + .../B06_Section_UI_Cross_Culvert_Basin.ts | 8 +- .../B06_Section_UI_Cross_Culvert_Const.ts | 35 +- B06_Section/B06_Section_UI_Cross_Ford.ts | 174 ++++++++++ B06_Section/B06_Section_UI_Cross_Ford_Geom.ts | 313 ++++++++++++++++++ .../B06_Section_UI_Cross_Ford_Panel.ts | 212 ++++++++++++ B06_Section/B06_Section_UI_Cross_View.ts | 119 +++++-- B06_Section/B06_Section_UI_Cross_View_Ford.ts | 52 +++ .../B06_Section_UI_Cross_View_Structure.ts | 42 +++ B06_Section/B06_Section_UI_Page.ts | 46 +-- .../B06_Section_UI_Page_Ford_Controls.ts | 136 ++++++++ B06_Section/B06_Section_UI_Page_Patches.ts | 68 ++++ .../B06_Section_UI_Page_Station_Controls.ts | 84 ++--- B06_Section/B06_Section_UI_Section_View.ts | 6 +- .../B06_Section_UI_Style_Cross_Areas.css | 16 + 24 files changed, 1504 insertions(+), 136 deletions(-) create mode 100644 B06_Section/B06_Section_UI_Cross_Ford.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Ford_Geom.ts create mode 100644 B06_Section/B06_Section_UI_Cross_Ford_Panel.ts create mode 100644 B06_Section/B06_Section_UI_Cross_View_Ford.ts create mode 100644 B06_Section/B06_Section_UI_Page_Ford_Controls.ts create mode 100644 B06_Section/B06_Section_UI_Page_Patches.ts diff --git a/B05_Profile/B05_Profile_Structure_Types.json b/B05_Profile/B05_Profile_Structure_Types.json index 28d71984..7545eb2c 100644 --- a/B05_Profile/B05_Profile_Structure_Types.json +++ b/B05_Profile/B05_Profile_Structure_Types.json @@ -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 } ] }, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts index 8fb9808c..3009cfd8 100644 --- a/B05_Profile/B05_Profile_UI_Corridor_Structures.ts +++ b/B05_Profile/B05_Profile_UI_Corridor_Structures.ts @@ -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 { + 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 => diff --git a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts index 84113560..1c19c4e4 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Facility.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Facility.ts @@ -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; }, diff --git a/B06_Section/B06_Section_Api_Fetch.ts b/B06_Section/B06_Section_Api_Fetch.ts index 0b2c244e..0730f9c4 100644 --- a/B06_Section/B06_Section_Api_Fetch.ts +++ b/B06_Section/B06_Section_Api_Fetch.ts @@ -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; @@ -382,6 +425,7 @@ export interface CrossSectionPatch { slopeM: number; }; revet_adjust?: Record; + ford_adjust?: StoredFordAdjust; extra_wall_counts?: StoredExtraWallCounts; /** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */ revet_link_detached?: boolean; diff --git a/B06_Section/B06_Section_Engine_Culvert.py b/B06_Section/B06_Section_Engine_Culvert.py index 2e192463..3eec1497 100644 --- a/B06_Section/B06_Section_Engine_Culvert.py +++ b/B06_Section/B06_Section_Engine_Culvert.py @@ -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 diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index e5dc4e8a..59ca42ba 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -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", diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index f618bbf1..ef8a713e 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -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: diff --git a/B06_Section/B06_Section_Router_Design.py b/B06_Section/B06_Section_Router_Design.py index da71fd09..7923cb70 100644 --- a/B06_Section/B06_Section_Router_Design.py +++ b/B06_Section/B06_Section_Router_Design.py @@ -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 diff --git a/B06_Section/B06_Section_Schema.py b/B06_Section/B06_Section_Schema.py index 03e13bd9..06d71c1a 100644 --- a/B06_Section/B06_Section_Schema.py +++ b/B06_Section/B06_Section_Schema.py @@ -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 사용자). 연동 해제는 측점별, 종단경사 반영은 # 기슭막이 한 벌 전체 공통이라 소유 측점에만 실린다. diff --git a/B06_Section/B06_Section_Section_Store.ts b/B06_Section/B06_Section_Section_Store.ts index 52e30914..f79113c8 100644 --- a/B06_Section/B06_Section_Section_Store.ts +++ b/B06_Section/B06_Section_Section_Store.ts @@ -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); diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts index 4bb8f8f0..2316cc62 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Basin.ts @@ -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" diff --git a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts index 7884ec2e..fcf58cbf 100644 --- a/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts +++ b/B06_Section/B06_Section_UI_Cross_Culvert_Const.ts @@ -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 = + { + 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, + }; +}; diff --git a/B06_Section/B06_Section_UI_Cross_Ford.ts b/B06_Section/B06_Section_UI_Cross_Ford.ts new file mode 100644 index 00000000..b21d32e5 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Ford.ts @@ -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(); + 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); + } + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts new file mode 100644 index 00000000..41cb451c --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Ford_Geom.ts @@ -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 }, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts new file mode 100644 index 00000000..7a5d3144 --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_Ford_Panel.ts @@ -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) => 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; +} diff --git a/B06_Section/B06_Section_UI_Cross_View.ts b/B06_Section/B06_Section_UI_Cross_View.ts index 06feff46..347c8550 100644 --- a/B06_Section/B06_Section_UI_Cross_View.ts +++ b/B06_Section/B06_Section_UI_Cross_View.ts @@ -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(); /** 마지막 계산의 실제 적용 d(상하) — d 미지정(자동) 벽의 ▲▼ 시작값. */ let culvertAppliedD = new Map(); @@ -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); diff --git a/B06_Section/B06_Section_UI_Cross_View_Ford.ts b/B06_Section/B06_Section_UI_Cross_View_Ford.ts new file mode 100644 index 00000000..51ab6c0d --- /dev/null +++ b/B06_Section/B06_Section_UI_Cross_View_Ford.ts @@ -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, + }; +} diff --git a/B06_Section/B06_Section_UI_Cross_View_Structure.ts b/B06_Section/B06_Section_UI_Cross_View_Structure.ts index b106fd92..60babcc5 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Structure.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Structure.ts @@ -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; + appliedD: Map; +} { + const wallSpecs = new Map(); + 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(); + 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, + }; +} diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index e9fc8f5c..dd7c52f7 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -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 { stationControls.extraWalls, stationControls.structureSpan, stationControls.revetLink, + stationControls.ford, ); // 메인 영역: 종·횡단 도면(sectionView) 또는 안내 메시지를 표시한다. @@ -509,42 +511,16 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { crossPatches: CrossSectionPatch[]; massHaul: Record | undefined; } { - // 암 경계 오프셋 + 측점 개별 표시 반폭을 chainage 기준으로 합쳐 한 패치로 보낸다. - const patchByChainage = new Map(); - 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 diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts new file mode 100644 index 00000000..ed63e270 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -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) => void; + refreshCard: (chainageM: number) => void; + /** 관경·수량을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */ + queuePipeOptions: (chainageM: number, patch: Record) => void; + round1: (value: number) => number; + clampMove: (value: number) => number; +} + +export interface FordControls { + control: FordControl; + /** 확정 payload용 — 측점별 조작값. */ + byChainage: () => Map; + /** 노선이 바뀔 때 세션 값을 다시 읽는다. */ + 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(); + const fordSelections = new Map(); + 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(); + fordAdjustments.forEach((adjust, key) => { + const value = Number(key); + if (Number.isFinite(value)) result.set(value, adjust); + }); + return result; + }, + load: loadFordAdjustments, + }; +} diff --git a/B06_Section/B06_Section_UI_Page_Patches.ts b/B06_Section/B06_Section_UI_Page_Patches.ts new file mode 100644 index 00000000..e8708193 --- /dev/null +++ b/B06_Section/B06_Section_UI_Page_Patches.ts @@ -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; + /** 측점 개별 표시 반폭(m). */ + stationWidths: Map; + inletStructures: Map; + basinAdjustments: Map; + revetAdjusts: Map>; + extraCounts: Map; + fordAdjusts: Map; + linkFlags: Map; +} + +/** 누가거리별 패치를 합쳐 한 배열로 돌려준다. */ +export function buildCrossPatches(sources: CrossPatchSources): CrossSectionPatch[] { + const patchByChainage = new Map(); + 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()]; +} diff --git a/B06_Section/B06_Section_UI_Page_Station_Controls.ts b/B06_Section/B06_Section_UI_Page_Station_Controls.ts index 6e744256..026d09e0 100644 --- a/B06_Section/B06_Section_UI_Page_Station_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Station_Controls.ts @@ -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; widths: Map; inletStructures: Map; basinAdjustments: Map; @@ -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 = { - 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(); diff --git a/B06_Section/B06_Section_UI_Section_View.ts b/B06_Section/B06_Section_UI_Section_View.ts index 83f8fa18..edd25dd9 100644 --- a/B06_Section/B06_Section_UI_Section_View.ts +++ b/B06_Section/B06_Section_UI_Section_View.ts @@ -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, ); /** diff --git a/B06_Section/B06_Section_UI_Style_Cross_Areas.css b/B06_Section/B06_Section_UI_Style_Cross_Areas.css index d5e3c76b..4b4e552f 100644 --- a/B06_Section/B06_Section_UI_Style_Cross_Areas.css +++ b/B06_Section/B06_Section_UI_Style_Cross_Areas.css @@ -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; +}