Files
Aislo/B06_Section/B06_Section_UI_Page_Ford_Controls.ts
T
eomsangdonandClaude Opus 5 f6296fa7c5 feat(B05·B06): 구조물 3D 투영 표기 · 직교 카메라 · 조정창 템플릿 일원화
3D 구조물과 성토면의 관계를 "잘라내기"에서 "투영 표기"로 바꾸고, 세월교·BOX암거
조정창을 기슭막이 템플릿으로 통일했다.

B05 코리도
- 구조물 탑뷰 투영 산출(_Corridor_Carve.ts 신규): 구조물 솔리드 링 사이 실좌표
  사각형 + 변형 성토면 영역. 노선 파라미터가 아니라 행 횡단선과 실좌표로 교차시켜
  곡선 구간·날개벽 벌어짐이 그대로 잡힌다.
- 사각형 합집합 경계(_Corridor_Union.ts 신규): 변을 교점에서 토막 내고 내부·공유
  토막을 버려 최외곽 윤곽만 남긴다(측정 133 -> 13 루프).
- 표기 모드(CARVE_APPLY=false): 서피스를 자르지 않고 ① 성토면 위 절단 투영선
  ② 원지반 최고 표고 위 Z평면에 구조물 윤곽(노랑)과 유입·유출 성토부 윤곽
  (하늘·주황)을 따로 그린다. 구조물 커브는 성토면 없다고 보고, 성토부 커브는
  구조물 없다고 보고 만든다.
- 구조물 실루엣 공용화(_Corridor_Station_Structure.ts 신규): 기슭막이·BOX암거·
  세월교 실루엣을 한 자료형으로. 성토면 강제 변형은 스위치로 끄고
  (STRUCTURE_SILHOUETTE_OVERRIDE), 노견 확장만 상시 적용(shoulderSpans).
- 평면 영역 분리(_Corridor_Region.ts), 절단기 일반화(clipGeometry가 영역 배열·
  전체 정점 속성 보간 — 리본 UV 유지). 지형은 코리도 스트립만 도려낸다.
- BOX암거 3D를 측점 프레임 직선 압출로(노선 추종 금지), 날개벽 계산 공용화
  (_Corridor_Structures_Wing.ts 신규) 후 세월교에도 날개벽 4매 추가.
- 세월교 보어를 관 련수만큼 뚫고, 보어 밴드를 단면 표고로 클램프해 구멍 찌그러짐
  제거. 날개벽 뿌리를 벽 외측면 모서리로 교정.
- 직교 카메라(_Viewer_Camera.ts 신규) — 원근 왜곡 없이 탑뷰 판독. 커서 피벗 줌이
  직교에서 camera.zoom을 함께 조인다.

B06 횡단
- 조정창 공용 부품(_Cross_Panel_Base.ts 신규): 머리줄·2행 항목·D-pad·키보드
  방향키. 세월교·BOX 조정창을 같은 템플릿으로 옮기고 십자 세트를 창 맨 아래로.
- 세월교 조정창에 날개벽 항목 추가(설치·짧은쪽 높이·길이·각도) — 정본
  pipe_points 되쓰기, 바닥판 연장은 길이×cos각으로 즉시 재계산.
- 세월교 좌우 이동을 집수정 규칙으로: 노견 수평 확장 + 상하 이동분만 성토선.

BUILD_VERSION 19 -> 31(저장 코리도 캐시 무효화). typecheck·prettier 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 20:03:42 +09:00

269 lines
11 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Page_Ford_Controls.ts
* 세월교 측벽·BOX암거 구체 조작값 제어 — 측점 제어기
* (`_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";
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";
export interface FordControlDeps {
/** 세션 보관 키(프로젝트·노선별). 없으면 세션에 담지 않는다. */
sessionKey: () => string | null;
sectionAt: (chainageM: number) => CrossSection | undefined;
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
refreshCard: (chainageM: number) => void;
/** 관경·수량·날개벽 제원을 B05 정본으로 되돌려 쓴다(묶어서 늦게 저장). */
queuePipeOptions: (chainageM: number, patch: Record<string, number | string>) => void;
round1: (value: number) => number;
clampMove: (value: number) => number;
}
export interface FordControls {
control: FordControl;
/** 확정 payload용 — 측점별 조작값. */
byChainage: () => Map<number, FordAdjust>;
/** 노선이 바뀔 때 세션 값을 다시 읽는다. */
load: () => void;
}
export function createFordControls(deps: FordControlDeps): FordControls {
const { round1, clampMove, sectionAt, patchCachedDesign } = deps;
/* ── 세월교 측벽 조작(2026-08-25 사용자) ───────────────────────────────
* 축은 집수정과 같다(높이·좌우·상하 대각). 값은 세션 사본 + 캐시 design에 함께
* 실어 3D가 같은 값으로 다시 그리게 한다. 관경·수량은 B05 정본이 원천이라
* 배수관 구간값과 같은 저장기(`pipe_points` 되쓰기)로 보낸다. */
const fordAdjustments = new Map<string, FordAdjust>();
const fordSelections = new Map<string, FordWallRole | null>();
const fordSessionKey = (): string | null => deps.sessionKey();
function loadFordAdjustments(): void {
fordAdjustments.clear();
const key = fordSessionKey();
if (!key) return;
try {
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
string,
FordAdjust
>;
Object.entries(parsed).forEach(([chainage, value]) =>
fordAdjustments.set(chainage, {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.inlet },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...value.outlet },
}),
);
} catch {
/* 손상된 세션 값은 기본값으로 대체. */
}
}
function persistFordAdjustments(): void {
const key = fordSessionKey();
if (key)
window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(fordAdjustments)));
}
const fordAdjustAt = (chainageM: number): FordAdjust => {
const stored = sectionAt(chainageM)?.design?.ford_adjust;
return (
fordAdjustments.get(chainageM.toFixed(2)) ?? {
inlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.inlet ?? {}) },
outlet: { ...DEFAULT_FORD_WALL_ADJUST, ...(stored?.outlet ?? {}) },
}
);
};
const writeFord = (chainageM: number, next: FordAdjust): void => {
fordAdjustments.set(chainageM.toFixed(2), next);
persistFordAdjustments();
patchCachedDesign(chainageM, { ford_adjust: next });
deps.refreshCard(chainageM);
};
const fordControl: FordControl = {
adjustFor: fordAdjustAt,
update: (chainageM, role, patch) => {
const current = fordAdjustAt(chainageM);
const wall: FordWallAdjust = { ...current[role], ...patch };
writeFord(chainageM, {
...current,
[role]: {
// 높이는 0.1m 눈금, 하한(관경+토피)은 기하가 다시 잡는다.
heightM: wall.heightM === null ? null : Math.max(round1(wall.heightM), 0),
lateralM: Math.max(0, clampMove(wall.lateralM)),
slopeM: clampMove(wall.slopeM),
},
});
},
reset: (chainageM, role) => {
const current = fordAdjustAt(chainageM);
writeFord(chainageM, { ...current, [role]: { ...DEFAULT_FORD_WALL_ADJUST } });
},
setPipe: (chainageM, patch) => {
const spec = sectionAt(chainageM)?.ford;
if (spec) {
// 캐시를 먼저 고쳐 즉시 반영한다 — 저장은 늦게 묶어서 간다.
if (patch.pipe_diameter_mm) spec.diameter_m = patch.pipe_diameter_mm / 1000;
if (patch.pipe_count) spec.pipe_count = patch.pipe_count;
}
deps.queuePipeOptions(chainageM, patch);
deps.refreshCard(chainageM);
},
setWing: (chainageM, role, patch) => {
const spec = sectionAt(chainageM)?.ford;
const wing = role === "inlet" ? spec?.wing_in : spec?.wing_out;
if (wing) {
// 캐시 먼저 — 바닥판 연장(길이 × cos각)도 백엔드 산식 그대로 다시 계산한다.
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;
}
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.refreshCard(chainageM);
},
selectedFor: (chainageM) => fordSelections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
fordSelections.set(chainageM.toFixed(2), role);
},
};
return {
control: fordControl,
byChainage: () => {
const result = new Map<number, FordAdjust>();
fordAdjustments.forEach((adjust, key) => {
const value = Number(key);
if (Number.isFinite(value)) result.set(value, adjust);
});
return result;
},
load: loadFordAdjustments,
};
}
/**
* BOX암거 구체 조작값 제어 — 세월교와 같은 흐름(세션 사본 + 캐시 `design.box_adjust`).
* 좌·우 끝을 따로 잡으며 길이는 바깥으로만, 표고는 양방향으로 움직인다.
*/
export function createBoxControls(deps: FordControlDeps): {
control: BoxControl;
byChainage: () => Map<number, BoxAdjust>;
load: () => void;
} {
const { round1, clampMove, sectionAt, patchCachedDesign } = deps;
const adjustments = new Map<string, BoxAdjust>();
const selections = new Map<string, BoxSideRole | null>();
function load(): void {
adjustments.clear();
const key = deps.sessionKey();
if (!key) return;
try {
const parsed = JSON.parse(window.sessionStorage.getItem(key) ?? "{}") as Record<
string,
BoxAdjust
>;
Object.entries(parsed).forEach(([chainage, value]) =>
adjustments.set(chainage, {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.left },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...value.right },
}),
);
} catch {
/* 손상된 세션 값은 기본값으로 대체. */
}
}
function persist(): void {
const key = deps.sessionKey();
if (key) window.sessionStorage.setItem(key, JSON.stringify(Object.fromEntries(adjustments)));
}
const adjustAt = (chainageM: number): BoxAdjust => {
const stored = sectionAt(chainageM)?.design?.box_adjust;
return (
adjustments.get(chainageM.toFixed(2)) ?? {
left: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.left ?? {}) },
right: { ...DEFAULT_BOX_SIDE_ADJUST, ...(stored?.right ?? {}) },
}
);
};
const write = (chainageM: number, next: BoxAdjust): void => {
adjustments.set(chainageM.toFixed(2), next);
persist();
patchCachedDesign(chainageM, { box_adjust: next });
deps.refreshCard(chainageM);
};
const control: BoxControl = {
adjustFor: adjustAt,
update: (chainageM, role, patch) => {
const current = adjustAt(chainageM);
const side: BoxSideAdjust = { ...current[role], ...patch };
write(chainageM, {
...current,
[role]: {
// 길이는 바깥으로만 — 안쪽으로 줄이면 성토선 물매가 깨진다.
lengthM: Math.max(0, round1(side.lengthM)),
riseM: clampMove(side.riseM),
},
});
},
reset: (chainageM, role) => {
const current = adjustAt(chainageM);
write(chainageM, { ...current, [role]: { ...DEFAULT_BOX_SIDE_ADJUST } });
},
selectedFor: (chainageM) => selections.get(chainageM.toFixed(2)) ?? null,
select: (chainageM, role) => {
selections.set(chainageM.toFixed(2), role);
},
};
return {
control,
byChainage: () => {
const result = new Map<number, BoxAdjust>();
adjustments.forEach((adjust, key) => {
const value = Number(key);
if (Number.isFinite(value)) result.set(value, adjust);
});
return result;
},
load,
};
}
/** 세월교·BOX암거 제어를 한 번에 만든다 — 측점 제어기 쪽 배선을 줄인다(700줄 제한). */
export function createBodyControls(
common: Omit<FordControlDeps, "sessionKey">,
sessionKey: (kind: "fordadjust" | "boxadjust") => string | null,
): {
ford: ReturnType<typeof createFordControls>;
box: ReturnType<typeof createBoxControls>;
} {
return {
ford: createFordControls({ ...common, sessionKey: () => sessionKey("fordadjust") }),
box: createBoxControls({ ...common, sessionKey: () => sessionKey("boxadjust") }),
};
}