diff --git a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts index cbc5410c..52a2a1f9 100644 --- a/B06_Section/B06_Section_UI_Cross_Box_Panel.ts +++ b/B06_Section/B06_Section_UI_Cross_Box_Panel.ts @@ -26,6 +26,9 @@ export interface BoxPanelDeps { nudgeRise: (role: BoxSideRole, deltaM: number) => void; /** 그 측을 자동 자리로 되돌린다. */ reset: (role: BoxSideRole) => void; + /** 이 측이 좌측 「구조물 배치」의 어느 칸인가 — 좌·우는 측점 지형이 정하고, 폼은 + * 유입/유출로만 부른다(2026-08-30 사용자: 조정창은 결국 지우고 폼 이름을 쓴다). */ + wingRoleFor?: (role: BoxSideRole) => "inlet" | "outlet"; /** 지금 그려진 구체 길이(m)와 물매(1:n, 수평이면 null). */ bodyLengthM: () => number; slopeRatio: () => number | null; @@ -117,6 +120,10 @@ export function buildBoxPanel(deps: BoxPanelDeps, opts?: { dock?: boolean }): Bo show(role) { current = role; root.classList.toggle("is-hidden", role === null); + // 좌측 [횡단 조정]에 어느 칸을 세울지 알린다 — 날개벽(유입)/(유출) 칸이다 + // (세월교와 같은 규약, 2026-08-30 사용자). + root.dataset.side = role === null ? "" : (deps.wingRoleFor?.(role) ?? ""); + root.dataset.panelTitle = role === null ? "" : "BOX암거"; arrows.detach(); if (role !== null) arrows.attach(); render(); @@ -129,6 +136,14 @@ export interface BoxControl { adjustFor: (chainageM: number) => { left: BoxSideAdjust; right: BoxSideAdjust }; update: (chainageM: number, role: BoxSideRole, patch: Partial) => void; reset: (chainageM: number, role: BoxSideRole) => void; + /** 본체 규격 저장 — 좌측 폼이 보낸다. B05 정본(`pipe_points`)으로 간다. */ + setBody: (chainageM: number, patch: { body_width_m?: number; body_height_m?: number }) => void; + /** 날개벽 제원 저장(유입·유출) — 세월교와 같은 옵션 키를 쓴다. */ + setWing: ( + chainageM: number, + role: "inlet" | "outlet", + patch: Partial<{ installed: boolean; height_m: number; length_m: number; angle_deg: number }>, + ) => void; selectedFor: (chainageM: number) => BoxSideRole | null; select: (chainageM: number, role: BoxSideRole | null) => void; } diff --git a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts index d48b917a..d8ab44c6 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Bodies.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Bodies.ts @@ -79,6 +79,7 @@ export function createBodyWiring(deps: BodyWiringDeps): BodyWiring { chainageM: chainage, box, layout: () => boxLayout, + inletOnLeft: (section.uphill_side ?? "left") === "left", close: () => { if (boxRole) toggleBox(boxRole); }, diff --git a/B06_Section/B06_Section_UI_Cross_View_Box.ts b/B06_Section/B06_Section_UI_Cross_View_Box.ts index 00ae694b..f0400d5e 100644 --- a/B06_Section/B06_Section_UI_Cross_View_Box.ts +++ b/B06_Section/B06_Section_UI_Cross_View_Box.ts @@ -13,6 +13,9 @@ export interface BoxPanelContext { box: BoxControl; /** 마지막 계산 결과 — 창이 길이·물매를 읽는다. */ layout: () => BoxLayout | null; + /** 유입(상류)이 화면 좌측인가 — 좌·우를 폼의 유입/유출 칸으로 옮길 때 쓴다 + * (기하가 날개벽을 고르는 기준과 같다, 2026-08-30). */ + inletOnLeft: boolean; close: () => void; } @@ -28,6 +31,7 @@ export function boxPanelDeps(context: BoxPanelContext): BoxPanelDeps { nudgeRise: (role, deltaM) => box.update(chainageM, role, { riseM: box.adjustFor(chainageM)[role].riseM + deltaM }), reset: (role) => box.reset(chainageM, role), + wingRoleFor: (role) => ((role === "left") === context.inletOnLeft ? "inlet" : "outlet"), bodyLengthM: () => context.layout()?.bodyLengthM ?? 0, slopeRatio: () => context.layout()?.slopeRatio ?? null, close: context.close, diff --git a/B06_Section/B06_Section_UI_Page.ts b/B06_Section/B06_Section_UI_Page.ts index 85535789..0e56e9ed 100644 --- a/B06_Section/B06_Section_UI_Page.ts +++ b/B06_Section/B06_Section_UI_Page.ts @@ -31,7 +31,7 @@ import { } from "./B06_Section_Api_Fetch"; import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { createStationControls } from "./B06_Section_UI_Page_Station_Controls"; -import { applyFordFormOptions } from "./B06_Section_UI_Page_Ford_Controls"; +import { applyBoxFormOptions, applyFordFormOptions } from "./B06_Section_UI_Page_Ford_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"; @@ -202,6 +202,12 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { applyFordFormOptions(stationControls.ford, owner.chainage_m, patch); return; } + // BOX암거도 스펙 자리가 따로다(`section.box`) — 같은 규칙으로 제어기에 보낸다 + // (2026-08-30 사용자: 세월교와 같은 문제). + if (owner.box) { + applyBoxFormOptions(stationControls.box, owner.chainage_m, patch); + return; + } const culvert = owner.culvert; if (!culvert) return; const num = (key: string): number | undefined => { diff --git a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts index 4be840fb..a787fd17 100644 --- a/B06_Section/B06_Section_UI_Page_Ford_Controls.ts +++ b/B06_Section/B06_Section_UI_Page_Ford_Controls.ts @@ -16,6 +16,44 @@ import { DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box"; import type { BoxAdjust, BoxSideAdjust, BoxSideRole } from "./B06_Section_UI_Cross_Box"; import type { BoxControl } from "./B06_Section_UI_Cross_Box_Panel"; +/** 날개벽 한 벌의 저장 키 — 세월교·BOX암거가 같은 옵션 이름을 쓴다(`wing_in*`/`wing_out*`). */ +type WingPatch = Partial<{ + installed: boolean; + height_m: number; + length_m: number; + angle_deg: number; +}>; + +/** 조작값을 관 지점 옵션 키로 옮긴다 — 값이 온 항목만 싣는다. */ +function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record { + const prefix = role === "inlet" ? "wing_in" : "wing_out"; + const options: Record = {}; + if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음"; + if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m; + if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m; + if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg; + return options; +} + +/** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */ +function wingPatchFrom(patch: Record, prefix: string): WingPatch { + const num = (key: string): number | undefined => { + if (patch[key] === undefined) return undefined; + const value = Number(patch[key]); + return Number.isFinite(value) ? value : undefined; + }; + const wing: WingPatch = {}; + // 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약). + if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음"; + const heightM = num(`${prefix}_height_m`); + if (heightM !== undefined) wing.height_m = heightM; + const lengthM = num(`${prefix}_length_m`); + if (lengthM !== undefined) wing.length_m = lengthM; + const angleDeg = num(`${prefix}_angle_deg`); + if (angleDeg !== undefined) wing.angle_deg = angleDeg; + return wing; +} + export interface FordControlDeps { /** 세션 보관 키(프로젝트·노선별). 없으면 세션에 담지 않는다. */ sessionKey: () => string | null; @@ -134,13 +172,7 @@ export function createFordControls(deps: FordControlDeps): FordControls { ? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0) : 0; } - const prefix = role === "inlet" ? "wing_in" : "wing_out"; - const options: Record = {}; - if (patch.installed !== undefined) options[prefix] = patch.installed ? "있음" : "없음"; - if (patch.height_m !== undefined) options[`${prefix}_height_m`] = patch.height_m; - if (patch.length_m !== undefined) options[`${prefix}_length_m`] = patch.length_m; - if (patch.angle_deg !== undefined) options[`${prefix}_angle_deg`] = patch.angle_deg; - deps.queuePipeOptions(chainageM, options); + deps.queuePipeOptions(chainageM, wingOptions(role, patch)); deps.refreshCard(chainageM); }, selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null, @@ -191,23 +223,50 @@ export function applyFordFormOptions( if (widthM !== undefined) pipe.ford_width_m = widthM; if (Object.keys(pipe).length) control.setPipe(chainageM, pipe); + applyWingFormOptions(control, chainageM, patch); +} + +/** 폼이 낸 날개벽 옵션을 제어기로 보낸다 — 세월교·BOX암거가 같은 조각을 쓴다. */ +function applyWingFormOptions( + control: { + setWing: (chainageM: number, role: "inlet" | "outlet", patch: WingPatch) => void; + }, + chainageM: number, + patch: Record, +): void { for (const [role, prefix] of [ ["inlet", "wing_in"], ["outlet", "wing_out"], ] as const) { - const wing: Parameters[2] = {}; - // 설치 값은 폼이 "있음"/"없음" 문자열로 낸다(백엔드 `_wing_spec`과 같은 규약). - if (patch[prefix] !== undefined) wing.installed = patch[prefix] !== "없음"; - const heightM = num(`${prefix}_height_m`); - if (heightM !== undefined) wing.height_m = heightM; - const lengthM = num(`${prefix}_length_m`); - if (lengthM !== undefined) wing.length_m = lengthM; - const angleDeg = num(`${prefix}_angle_deg`); - if (angleDeg !== undefined) wing.angle_deg = angleDeg; + const wing = wingPatchFrom(patch, prefix); if (Object.keys(wing).length) control.setWing(chainageM, role, wing); } } +/** + * 좌측 「구조물 배치」 폼이 낸 BOX암거 옵션을 조정창 제어기로 흘려보낸다 — 세월교와 + * 같은 규칙이다(2026-08-30 사용자). 본체 규격·날개벽만 폼이 정하고, 구체 길이·표고는 + * 조정창 축이라 여기 오지 않는다. + */ +export function applyBoxFormOptions( + control: BoxControl, + chainageM: number, + patch: Record, +): void { + const num = (key: string): number | undefined => { + if (patch[key] === undefined) return undefined; + const value = Number(patch[key]); + return Number.isFinite(value) ? value : undefined; + }; + const body: Parameters[1] = {}; + const widthM = num("body_width_m"); + if (widthM !== undefined) body.body_width_m = widthM; + const heightM = num("body_height_m"); + if (heightM !== undefined) body.body_height_m = heightM; + if (Object.keys(body).length) control.setBody(chainageM, body); + applyWingFormOptions(control, chainageM, patch); +} + /** * BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 사본 + 캐시 `design.box_adjust`). * 좌·우 끝을 따로 잡으며 길이는 바깥으로만, 표고는 양방향으로 움직인다. @@ -281,6 +340,34 @@ export function createBoxControls(deps: FordControlDeps): { const current = adjustAt(chainageM); write(chainageM, { ...current, [role]: { ...DEFAULT_BOX_SIDE_ADJUST } }); }, + setBody: (chainageM, patch) => { + const spec = sectionAt(chainageM)?.box; + if (spec) { + // 캐시 먼저 — 구체 길이(`span_m`)는 백엔드 `_box_set`과 같은 식으로 다시 잡는다. + if (patch.body_width_m) { + spec.inner_width_m = patch.body_width_m; + spec.span_m = patch.body_width_m + 2 * spec.wall_thickness_m; + } + if (patch.body_height_m) spec.inner_height_m = patch.body_height_m; + } + deps.queuePipeOptions(chainageM, patch as Record); + deps.refreshCard(chainageM); + }, + setWing: (chainageM, role, patch) => { + const spec = sectionAt(chainageM)?.box; + const wing = role === "inlet" ? spec?.wing_in : spec?.wing_out; + if (wing) { + if (patch.installed !== undefined) wing.installed = patch.installed; + if (patch.height_m !== undefined) wing.height_m = patch.height_m; + if (patch.length_m !== undefined) wing.length_m = patch.length_m; + if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg; + wing.slab_extend_m = wing.installed + ? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0) + : 0; + } + deps.queuePipeOptions(chainageM, wingOptions(role, patch)); + deps.refreshCard(chainageM); + }, selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null, select: (chainageM, role) => { selections.set(chainageM.toFixed(2), role); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 991529df..fabc9eb7 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -128,6 +128,30 @@ function withFordSpec( return merged; } +/** + * BOX암거 폼 옵션을 **지금 그려진 스펙**(`section.box`)으로 덮는다 — 세월교와 같은 + * 규칙이다(2026-08-30 사용자). 본체 규격은 내공 폭·높이가 정본이고, 구체 길이·표고는 + * 조정 채널이라 여기 오지 않는다. + */ +function withBoxSpec( + options: Record | undefined, + box: NonNullable, +): Record { + const merged: Record = { ...(options ?? {}) }; + merged.body_width_m = box.inner_width_m; + merged.body_height_m = box.inner_height_m; + for (const [wing, prefix] of [ + [box.wing_in, "wing_in"], + [box.wing_out, "wing_out"], + ] as const) { + merged[prefix] = wing.installed ? "있음" : "없음"; + if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m; + if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m; + if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg; + } + return merged; +} + /** * 관 지점 옵션에 **횡단 스펙 값**을 채워 넣는다 — 조정창이 보여주던 값과 폼이 * 어긋나지 않게 한다(2026-08-29 사용자). 옵션에 이미 값이 있으면 그대로 두고, @@ -147,6 +171,7 @@ function withSpecDefaults( // 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는 // 구조물 배치 상세 UI, 로직·값은 조정창 것). if (owner?.ford) return withFordSpec(options, owner.ford); + if (owner?.box) return withBoxSpec(options, owner.box); const culvert = owner?.culvert; if (!culvert) return options; const merged: Record = { ...(options ?? {}) }; @@ -458,6 +483,8 @@ export function wireStructureSelection( markWallSelecting(); origBox(chainageM, role); panel.showPipeAt(role ? chainageM : null); + // 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다. + refreshAdjustSlots(); }; return { syncInletStructure }; }