feat(B06): BOX암거 좌측 폼을 세월교와 같은 규칙으로 연결한다
세월교와 같은 문제가 BOX암거에도 있었다 — 좌측 「구조물 배치」의 본체 규격·날개벽을 바꿔도 도면이 그대로였고, 폼 기본값이 스펙에서 오지 않았으며, 구체를 골라도 좌측 칸이 서지 않았다. `applyPipeOptionsToCache`가 `section.box`를 모르고 즉시 return하던 것이 뿌리다(세월교와 동일). - `applyBoxFormOptions()` — 폼 옵션(`body_*`·`wing_*`)을 제어기로 보낸다. `BoxControl`에 `setBody`·`setWing`을 더했다: 캐시(`section.box`)를 먼저 고치고 (`span_m`은 백엔드 `_box_set`과 같은 식으로 다시 잡는다) 정본 예약 + 카드 갱신. - 날개벽 옵션 조립·해석을 `wingOptions()`·`wingPatchFrom()`으로 묶어 세월교와 나눠 쓴다 — 세월교 쪽 중복 코드가 사라졌다. - `withBoxSpec()` — 폼 기본값을 `section.box`(내공 폭·높이, 날개벽)에서 읽는다. - 좌·우 끝을 고르면 폼의 **날개벽(유입)/(유출)** 칸이 선다. 좌·우 ↔ 유입/유출 매핑은 기하가 날개벽을 고르는 기준(`uphill_side`)과 같다. 조정창은 결국 지우고 폼 이름(유입/유출)만 쓰기로 한 사용자 확정(2026-08-30)을 따랐다. 구체 길이·표고는 폼에 대응 칸이 없어 조정창 축으로 남는다(본체 높이는 규격이 이미 정한다 — 2026-08-30 사용자). 자체검증(공용 브라우저 5174 + 워크트리 백엔드 8001, 측점 10+0.9 BOX암거): - tsc --noEmit 통과. - 구체 선택 시 좌측 `날개벽(유입) (BOX암거)` 칸 강조(수정 전 강조 없음). - 본체 규격 2.0×2.0 → 3.0×3.0 → 도면 라벨 `BOX 3.0×3.0`, 다시 2.0×2.0 → `BOX 2.0×2.0`. 한 번 고른 상태에서 연속 조작도 그대로 먹는다. 수정 전에는 라벨이 5.34m 구체와 함께 전혀 움직이지 않았다. - 회귀: 세월교 날개 길이 2→4→2에서 조정창 표시가 4.0m→2.0m로 따라온다 (`wingOptions`/`wingPatchFrom` 공용화 뒤에도 동일). - 조작값은 원래대로(BOX 2.0×2.0 · 세월교 날개 길이 2m) 되돌려 놓았다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<BoxSideAdjust>) => 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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 => {
|
||||
|
||||
@@ -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<string, number | string> {
|
||||
const prefix = role === "inlet" ? "wing_in" : "wing_out";
|
||||
const options: Record<string, number | string> = {};
|
||||
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<string, number | string>, 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<string, number | string> = {};
|
||||
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<string, number | string>,
|
||||
): void {
|
||||
for (const [role, prefix] of [
|
||||
["inlet", "wing_in"],
|
||||
["outlet", "wing_out"],
|
||||
] as const) {
|
||||
const wing: Parameters<FordControl["setWing"]>[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<string, number | string>,
|
||||
): 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<BoxControl["setBody"]>[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<string, number | string>);
|
||||
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);
|
||||
|
||||
@@ -128,6 +128,30 @@ function withFordSpec(
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* BOX암거 폼 옵션을 **지금 그려진 스펙**(`section.box`)으로 덮는다 — 세월교와 같은
|
||||
* 규칙이다(2026-08-30 사용자). 본체 규격은 내공 폭·높이가 정본이고, 구체 길이·표고는
|
||||
* 조정 채널이라 여기 오지 않는다.
|
||||
*/
|
||||
function withBoxSpec(
|
||||
options: Record<string, string | number> | undefined,
|
||||
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
|
||||
): Record<string, string | number> {
|
||||
const merged: Record<string, string | number> = { ...(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<string, string | number> = { ...(options ?? {}) };
|
||||
@@ -458,6 +483,8 @@ export function wireStructureSelection(
|
||||
markWallSelecting();
|
||||
origBox(chainageM, role);
|
||||
panel.showPipeAt(role ? chainageM : null);
|
||||
// 폼이 이 시설로 갈아 끼워진 뒤에 이식을 다시 돌린다 — 날개벽 칸이 그제야 선다.
|
||||
refreshAdjustSlots();
|
||||
};
|
||||
return { syncInletStructure };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user