Files
Aislo/B06_Section/B06_Section_UI_Cross_View_Structure.ts
T
eomsangdonandClaude Opus 5 81bb505cc4 feat(B05·B06): 세월교 횡단 구체·조정창·3D 예상형상
B05 세월교 옵션에 BOX암거 날개벽 한 벌(설치·짧은쪽 높이 1m·길이 2m·각도 45°)을
그대로 붙이고, B06 횡단도에 세월교 구체를 그린다. 구체는 양측 ㄴ형 집수정 측벽 +
그 사이를 잇는 바닥판 + 바닥판 상면에 안힌 관 + 관 위 성토(노체) 채움이다.

- 바닥판 편측 연장 = 날개벽 길이 × cos(각도) — 각도는 관축 기준 벌어짐각
- 측벽은 buildBasin 재사용이라 1:0.3 기움·좌우·상하 대각 이동·계류측 성토부선이
  집수정과 같은 규칙으로 따라온다 (기슭막이 다단·재질과는 연계하지 않음)
- 구체는 월류 폭만큼 도로 방향으로 이어져 기준 측점 전후 절반까지 붙는다
- 조정창: 측벽 높이·좌우·상하, 관경·수량(정본 pipe_points 되쓰기).
  높이·좌우·상하는 세션 + design.ford_adjust에 저장
- 3D 예상형상: 구체 부재는 월류 폭 구간 스윕, 관은 련수만큼 폭 안 등간격
- 부재 두께: 바닥판 0.3m(교본 물넘이 물받이 최소 30cm), 측벽 0.2m(집수정 승계)

700줄 제한으로 카드 렌더러·측점 제어기·페이지에서 배선을 분리했다
(_Cross_View_Ford, _Page_Ford_Controls, _Page_Patches, Culvert_Const 구간값 헬퍼).

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

223 lines
10 KiB
TypeScript

/* =============================================================================
* B06_Section_UI_Cross_View_Structure.ts
* 횡단 카드 ↔ **구조물 조정창** 배선 — 카드 렌더러(`_UI_Cross_View.ts`)에서 700줄
* 제한으로 분리했다(2026-08-24). 조정창이 부르는 동작을 제어기(기슭막이 4축·유입
* 구조물·다단·구간값·연동)로 넘기는 얇은 층이고, 기하나 그리기는 하지 않는다.
* ========================================================================== */
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";
import { L } from "./B06_Section_UI_Section_Common";
import type {
ExtraWallControl,
InletStructureControl,
RevetLinkControl,
RevetOffsetControl,
SpanRole,
StructureSpanControl,
} from "./B06_Section_UI_Cross_Culvert_Wire";
/**
* 선택된 벽이 구간값을 갖는 요소인지 — 유입·유출 기슭막이와 유입 집수정만 자기
* 길이·전/후를 갖는다. 다단(extra·bextra)은 소유 벽 연장을 그대로 따른다.
*/
export function spanRoleOf(key: RevetKey, inletIsBasin = false): SpanRole | null {
if (key === "inlet") return inletIsBasin ? "basin" : "inlet";
if (key === "outlet") return "outlet";
return null;
}
/**
* 조정창 배선에 필요한 카드 상태·제어기 묶음. 마지막 계산 결과(높이·재질·단 수 등)는
* 카드가 그릴 때마다 바뀌므로 **값이 아니라 읽는 함수**로 받는다.
*/
export interface StructurePanelContext {
section: CrossSection;
/**
* 4축·다단 조작이 실제로 실릴 측점의 누가거리. 연동 중인 링크 카드에서는 **소유
* 측점**이다 — 연동은 "본 기슭막이를 조절하면 같이 움직인다"는 뜻이므로 조작값이
* 한 곳에만 있어야 한다(2026-08-24 사용자). 연동을 풀면 이 카드 자신이 된다.
*/
adjustChainage: () => number;
/** 이 카드가 옆 측점에서 연장돼 온 링크 카드인가. */
isLinked: boolean;
revetOffset?: RevetOffsetControl;
inletStructure?: InletStructureControl;
extraWalls?: ExtraWallControl;
structureSpan?: StructureSpanControl;
revetLink?: RevetLinkControl;
adjustOf: (key: RevetKey) => WallAdjust;
/** 화면 좌(◀) 방향을 벽 기준 부호로 환산한다. */
outwardOf: (key: RevetKey) => number;
heightOfWall: (key: RevetKey) => number;
materialOfWall: (key: RevetKey) => RevetMaterial;
/** 마지막 계산의 실제 적용 d(상하) — d 미지정 벽의 ▲▼ 시작값. */
appliedD: () => Map<RevetKey, number>;
pipeLengthM: () => number | null;
inletIsBasin: () => boolean;
inletOptions: () => { revetAllowed: boolean; basinLUAllowed: boolean };
extraState: () => { canAdd: boolean; count: number };
basinExtraState: () => { canAdd: boolean; count: number };
activeRevet: () => RevetKey | null;
setActiveRevet: (key: RevetKey | null) => void;
toggleRevet: (key: RevetKey) => void;
/** 창 스크롤 자리를 기억할 카드 키(측점 id). */
scrollKey: string;
}
export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDeps {
return {
scrollKey: ctx.scrollKey,
adjustFor: ctx.adjustOf,
nudge: (key, screenDeltaM) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
x: ctx.adjustOf(key).x + screenDeltaM * ctx.outwardOf(key),
}),
// d 미지정(자동)이면 실제 적용 자리에서부터 움직인다(2026-08-23 — d는 0점
// 기준 절대값이라 시작값이 필요하다).
nudgeSlope: (key, deltaM) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
d: (ctx.adjustOf(key).d ?? ctx.appliedD().get(key) ?? 0) + deltaM,
}),
nudgeHeight: (key, deltaM) => {
const current = ctx.adjustOf(key);
const base = current.h ?? ctx.heightOfWall(key);
ctx.revetOffset?.update(ctx.adjustChainage(), key, {
h: Math.round((base + deltaM) * 10) / 10,
});
},
materialFor: (key) => ctx.materialOfWall(key),
setMaterial: (key, material) =>
ctx.revetOffset?.update(ctx.adjustChainage(), key, { m: material }),
heightFor: (key) => ctx.heightOfWall(key),
reset: (key) => ctx.revetOffset?.reset(ctx.adjustChainage(), key),
pipeLengthM: () => ctx.pipeLengthM(),
close: () => {
const active = ctx.activeRevet();
if (active) ctx.toggleRevet(active);
},
structureFor: () => ctx.inletStructure?.valueFor(ctx.section) ?? "auto",
setStructure: (value) => ctx.inletStructure?.set(ctx.adjustChainage(), value),
basinAdjustFor: () =>
ctx.inletStructure?.adjustFor(ctx.section) ?? {
innerWidthM: 1,
innerHeightM: 1.2,
lateralM: 0,
slopeM: 0,
},
updateBasin: (patch) =>
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
...ctx.inletStructure.adjustFor(ctx.section),
...patch,
}),
// 집수정 좌우는 **화면 기준**으로 받는다(2026-08-22 사용자 확정) — 유입이 우측인
// 측점에서 ◀가 화면 오른쪽으로 가던 반전을 없앤다. 노견 안쪽(음수)은 금지라
// 0에서 멈추고, 멈춘 사실은 토스트로 알린다(기슭막이와 같은 규칙).
nudgeBasinLateral: (screenDeltaM) => {
const current = ctx.inletStructure?.adjustFor(ctx.section);
if (!current) return;
const next = current.lateralM + screenDeltaM * ctx.outwardOf("inlet");
if (next < -1e-9) {
showToast(L("B06_Cross_Revet_Limit_Inward"), "info");
}
ctx.inletStructure?.updateAdjust(ctx.adjustChainage(), {
...current,
lateralM: Math.max(0, Math.round(next * 10) / 10),
});
},
resetBasin: () => ctx.inletStructure?.resetAdjust(ctx.adjustChainage()),
// 집수정은 자리 고정 — 유입이 집수정이면 ◀/▶/↺를 숨긴다(추가 벽은 항상 이동).
canNudge: (key) => key !== "inlet" || !ctx.inletIsBasin(),
optionsFor: () => ctx.inletOptions(),
extraState: () => ctx.extraState(),
basinExtraState: () => ctx.basinExtraState(),
setExtraCount: (count) => {
// 줄어들며 사라지는 벽이 선택 중이면 선택을 유출 벽으로 옮긴다(빈 대상 방지).
const active = ctx.activeRevet() ?? "";
if (
active.startsWith("extra") &&
!active.startsWith("bextra") &&
Number(active.slice(5)) >= count
) {
ctx.setActiveRevet("outlet");
ctx.revetOffset?.select(ctx.section.chainage_m, "outlet");
}
ctx.extraWalls?.setCount(ctx.adjustChainage(), count, "outlet");
},
setBasinExtraCount: (count) => {
const activeBasin = ctx.activeRevet() ?? "";
if (activeBasin.startsWith("bextra") && Number(activeBasin.slice(6)) >= count) {
ctx.setActiveRevet("inlet");
ctx.revetOffset?.select(ctx.section.chainage_m, "inlet");
}
ctx.extraWalls?.setCount(ctx.adjustChainage(), count, "basin");
},
equalizeExtras: () => ctx.extraWalls?.equalize(ctx.adjustChainage()),
// 구간값(길이·전·후) — 다단은 소유 벽 연장을 따르는 파생물이라 대상이 아니다.
// 링크 카드에서 만져도 제어기가 소유 측점 값을 고친다(원천은 하나).
spanFor: (key) => {
const role = spanRoleOf(key, ctx.inletIsBasin());
return role && ctx.structureSpan ? ctx.structureSpan.valuesFor(ctx.section, role) : null;
},
setSpan: (key, patch) => {
const role = spanRoleOf(key, ctx.inletIsBasin());
if (role) ctx.structureSpan?.update(ctx.section, role, patch);
},
linkState: () =>
ctx.isLinked ? { linked: ctx.revetLink?.linkedFor(ctx.section) ?? true } : null,
setLinked: (linked) => ctx.revetLink?.setLinked(ctx.section.chainage_m, linked),
followGrade: () =>
ctx.revetLink?.followGradeFor(ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section) ?? true,
setFollowGrade: (follow) => {
const owner = ctx.structureSpan?.ownerOf(ctx.section) ?? ctx.section;
ctx.revetLink?.setFollowGrade(owner, follow);
},
};
}
/**
* 조정창이 쓸 **카드 상태**를 마지막 기하 결과에서 뽑는다(2026-08-25 분리 — 카드
* 렌더러 700줄). 값 계산은 하지 않고 레이아웃 결과를 조정창 키 체계로 옮기기만 한다.
*/
export function culvertCardState(layout: CulvertLayout): {
inletOptions: CulvertLayout["inletOptions"];
extraState: { canAdd: boolean; count: number };
basinExtraState: { canAdd: boolean; count: number };
wallSpecs: Map<RevetKey, { height: number; material: RevetMaterial }>;
appliedD: Map<RevetKey, number>;
} {
const wallSpecs = new Map<RevetKey, { height: number; material: RevetMaterial }>();
const specOf = (wall: CulvertLayout["walls"][number], key: RevetKey): void => {
wallSpecs.set(key, {
// 배관 벽은 순수 높이(바닥~상단 = 계산용 + 근입 0.5 — 2026-08-23 사용자).
height: wall.role === "extra" ? wall.height : wall.height + REVET_EMBED_DEPTH_M,
material: wall.material,
});
};
for (const wall of layout.walls) specOf(wall, wall.role as RevetKey);
layout.extraWalls.forEach((wall, i) => specOf(wall, `extra${i}` as RevetKey));
layout.basinExtras.forEach((wall, i) => specOf(wall, `bextra${i}` as RevetKey));
const appliedD = new Map<RevetKey, number>();
appliedD.set("inlet", layout.revetShift.inlet.d ?? 0);
appliedD.set("outlet", layout.revetShift.outlet.d ?? 0);
layout.revetShift.extras.forEach((applied, i) =>
appliedD.set(`extra${i}` as RevetKey, applied.d ?? 0),
);
layout.revetShift.basinExtras.forEach((applied, i) =>
appliedD.set(`bextra${i}` as RevetKey, applied.d ?? 0),
);
return {
inletOptions: layout.inletOptions,
extraState: { canAdd: layout.outletFill.addable, count: layout.extraWalls.length },
basinExtraState: { canAdd: layout.basinFill.addable, count: layout.basinExtras.length },
wallSpecs,
appliedD,
};
}