feat(511-B B-3 판 종단 줄 엶): 계산 자리 profile 줄 = 고를 수 있음(기본값 both 그대로 · 판 파일 profile both) · 「서버」 = 묶음 planline 새 지반 첫 계획선 꼭짓점(profile.base) · profile 단계 계획선 짓기(profile.alignment)를 창구 → 서버 Node 같은 TS(design_calc_profile.ts) · both = 단계 동기 그대로(묶음 차례 바뀜 0) · 서버 실패 = 경고 · 브라우저 값 · 까닭 글 pair_B3 → 끄면 서버 Node — 요구 320-4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 14:26:11 +09:00
co-authored by Claude Opus 5.5
parent a88faa731b
commit e043aee953
8 changed files with 150 additions and 42 deletions
+91
View File
@@ -0,0 +1,91 @@
/* =============================================================================
* design_calc_profile.ts
* 계산 자리 「서버」 종단 길(PLAN 511-B B-3) — 판(`design_calc_sites.ts`)이 profile 묶음을 server 로 둔 때
* 묶음 planline 의 새 지반 첫 계획선 꼭짓점(P1 · `profile.base`)과 profile 단계 계획선 짓기(P2 ~ P4 · P8 ·
* `profile.alignment`)를 서버 Node 가 같은 TS 로 셈(창구 `POST /projects/{id}/calc`). both 면 브라우저 그대로.
* 서버 실패(끊김 밖) = 경고 한 줄 · 브라우저 값.
* ========================================================================== */
import { calcOnServerNow } from "./design_calc_sites";
import { serverCalc } from "./design_calc_server";
import type { StageFn } from "./logic/design_pipeline_types";
import {
groundFollowingBasePvi,
groundProfile,
type GroundFollowingInput,
} from "../../B05_Profile/logic/B05_Profile_Grade_Ground";
import { profileStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
import {
emptyEdits,
type AlignmentNode,
type ProfileAlignment,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
const KEY = "profile";
const r6 = (value: number): number => Number(value.toFixed(6));
/** 서버 값 — 끊김은 그대로 던짐(묶음이 버림) · 그 밖 실패는 브라우저 값. */
async function orLocal<T>(signal: AbortSignal, remote: () => Promise<T>, local: () => T) {
try {
return await remote();
} catch (error) {
if (signal.aborted) throw error;
console.warn("[calc-server] profile 실패 — 브라우저 값", error);
return local();
}
}
/** planline `groundPvi` — 서버면 첫 계획선 꼭짓점 약속(0.000001 m · `groundFollowingBasePvi` 꼴) · 아니면 null
* (브라우저 셈 · 단계 동기). 판은 앞 ground 단계가 이미 받음(`calcOnServerNow`). */
export function groundPviAt(
input: GroundFollowingInput,
signal: AbortSignal,
): Promise<AlignmentNode[] | null> | null {
if (!calcOnServerNow(KEY)) return null;
const local = () => groundFollowingBasePvi(input);
const profile = groundProfile(input.samples);
if (!profile) return null;
return orLocal(
signal,
async () => {
const follow = {
stations: input.stations,
start_offset_m: input.startOffsetM ?? 0,
end_offset_m: input.endOffsetM ?? 0,
};
const inputs = { profile: { ...profile, policy: input.policy, ground_following: follow } };
const results = await serverCalc(KEY, ["profile.base"], inputs, signal);
const nodes = results["profile.base"] as AlignmentNode[] | null;
return (
nodes?.map((node) => ({
chainage_m: r6(node.chainage_m),
elevation_m: r6(node.elevation_m),
})) ?? null
);
},
local,
);
}
/** profile 단계 — 첫 계획선 + 계획고 편집 → 종단(서버면 `profile.alignment` · 아니면 동기 그대로). */
export const profileAt: StageFn<"profile"> = (ctx) => {
const base = ctx.out.planline;
if (!base || !calcOnServerNow(KEY)) return profileStage(ctx);
return orLocal(
ctx.signal,
async () => {
const profile = {
chainage: base.chainage,
ground: base.ground,
policy: base.policy,
stations: base.stations,
base_pvi: base.basePvi,
edits: ctx.inputs.alignment ?? emptyEdits(),
only_explicit_curves: base.onlyExplicitCurves,
};
const results = await serverCalc(KEY, ["profile.alignment"], { profile }, ctx.signal);
return results["profile.alignment"] as ProfileAlignment;
},
() => profileStage(ctx) as ProfileAlignment,
);
};
+3 -2
View File
@@ -87,9 +87,9 @@ import {
createFollowStage,
createPlanlineStage,
gradeRange,
profileStage,
routeRange,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
import { groundPviAt, profileAt } from "./design_calc_profile";
import {
draftSectionDetail,
shapeStatus,
@@ -299,8 +299,9 @@ function createPagePipeline(
savedBase: (at) => options.plantedBase?.(savedSource(), at, types) ?? savedSource().base,
savedLine,
storedPvi,
groundPvi: groundPviAt, // 계산 자리 「서버」 = 서버 Node(511-B B-3)
}),
profile: profileStage,
profile: profileAt,
// 단면 유형 = 새 지형 상단측 · 소단 = 지금 구조물(365-5) · 저장 노선 = 다시 셈 창구 한 벌(365-4) · 엔진은
// 창구 출력을 앞 결과로 안 이어 씀(초안 = 새로고침)
cross: (ctx) => {
+44 -34
View File
@@ -52,7 +52,11 @@ import {
} from "./B05_Profile_Grade_Carry";
import { seatBornNodes } from "./B05_Profile_Grade_Seat";
import { SECTION_STATION_INTERVAL_M } from "./B05_Profile_Sections_Stations";
import { groundFollowingBasePvi, groundProfile } from "./B05_Profile_Grade_Ground";
import {
groundFollowingBasePvi,
groundProfile,
type GroundFollowingInput,
} from "./B05_Profile_Grade_Ground";
import { DEFAULT_CURVE_LENGTH_M } from "./B05_Profile_Profile_CurveLaw";
const xy = (line: ReadonlyArray<Vertex>): Array<{ x: number; y: number }> =>
@@ -295,6 +299,12 @@ export interface PlanlineDeps {
savedLine(): Vertex[] | null;
/** 노선 초안 첫 계획선 꼭짓점 = 서버 저장 종단 — 보기가 심은 측점(초안 자리 관 등)에 안 흔들림(365-5) · 없으면 `savedBase`. */
storedPvi?(): AlignmentBase["basePvi"] | null;
/** 바뀐 자리 새 지반 첫 계획선 꼭짓점 — 계산 자리 「서버」 면 서버 Node 약속(511-B B-3) · null = 브라우저
* `groundFollowingBasePvi`(단계 동기 그대로). */
groundPvi?(
input: GroundFollowingInput,
signal: AbortSignal,
): Promise<AlignmentBase["basePvi"] | null> | null;
}
export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
@@ -311,13 +321,6 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
}));
const profile = groundProfile(samples);
if (!profile) return saved; // 못 서는 땅 — 저장 기준 그대로(서버가 [저장] 때 다시 셈)
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438)
const pviAt = (stations: number[]) =>
groundFollowingBasePvi({
samples,
stations,
policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M },
}) ?? [];
const savedLine = deps.savedLine();
const span = savedLine ? changedSpan(xy(savedLine), xy(route.line)) : null;
const savedPvi = deps.storedPvi?.() ?? saved.basePvi;
@@ -344,33 +347,40 @@ export function createPlanlineStage(deps: PlanlineDeps): StageFn<"planline"> {
...ground.stations.filter((station) => !born.includes(station.chainageM)),
...gone.map((chainageM) => ({ chainageM, kind: "structure" as const, id: null })),
];
const carried = span
? carryBase(
xy(savedLine!),
xy(route.line),
savedPvi,
pviAt(homeStations.map((station) => station.chainageM)),
)
: savedPvi;
const home = span
? seatRouteMoved(
baseOf(carried),
routeMovedStations(homeStations, xy(savedLine!), xy(route.line)),
edited.map(Number),
)
: carried;
const step = gone.length || born.length ? followMovedNodes(home, gone, born, edited) : null;
return {
policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝)
onlyExplicitCurves: saved.onlyExplicitCurves,
basePvi: step ? seatBornNodes(baseOf(home), step.nodes, step.seat, step.held) : home,
chainage: profile.chainage,
ground: profile.ground,
stations: ground.stations.map((station) => ({
station_id: stationIdOf(station.chainageM),
chainage_m: station.chainageM,
})),
const finish = (groundPvi: AlignmentBase["basePvi"]): AlignmentBase => {
const carried = span
? carryBase(xy(savedLine!), xy(route.line), savedPvi, groundPvi)
: savedPvi;
const home = span
? seatRouteMoved(
baseOf(carried),
routeMovedStations(homeStations, xy(savedLine!), xy(route.line)),
edited.map(Number),
)
: carried;
const step = gone.length || born.length ? followMovedNodes(home, gone, born, edited) : null;
return {
policy: saved.policy, // 옛 기본 L 이어받음(서버 `carry_policy` 짝)
onlyExplicitCurves: saved.onlyExplicitCurves,
basePvi: step ? seatBornNodes(baseOf(home), step.nodes, step.seat, step.held) : home,
chainage: profile.chainage,
ground: profile.ground,
stations: ground.stations.map((station) => ({
station_id: stationIdOf(station.chainageM),
chainage_m: station.chainageM,
})),
};
};
// 바뀐 자리 새 꼭짓점 = 서버 새 지반 첫 계획선(기본 L = config 짝 · 초기값 직선화 · 438) · 계산 자리
// 「서버」 면 서버 Node(`groundPvi` · 511-B B-3)
const input = {
samples,
stations: homeStations.map((station) => station.chainageM),
policy: { ...saved.policy, default_curve_length_m: DEFAULT_CURVE_LENGTH_M },
};
const remote = span ? deps.groundPvi?.(input, ctx.signal) : null;
if (remote) return remote.then((nodes) => finish(nodes ?? []));
return finish(span ? (groundFollowingBasePvi(input) ?? []) : []);
};
}
+4 -3
View File
@@ -46,12 +46,13 @@ GROUPS: tuple[dict[str, Any], ...] = (
"fixed": "both",
"why": "pair_B2",
},
# 종단 = 짝 모음 끝(B-3 · 서버 Node `profile.base` · `profile.alignment`) — 엶
{
"key": "profile",
"nos": ["P1", "P2", "P3", "P4", "P8"],
"switchable": False,
"fixed": "both",
"why": "pair_B3",
"switchable": True,
"fixed": None,
"why": None,
},
{
"key": "cross",
@@ -5,6 +5,7 @@
"corridor_mesh": "both",
"quantity": "both",
"carry": "both",
"profile": "both",
"fixed_browser": "both"
}
}
@@ -63,6 +63,7 @@ def test_묶음_여덟_줄_등록부_전부():
"corridor_mesh",
"quantity",
"carry",
"profile", # 511-B B-3 엶
"fixed_browser",
} # 511-A 셋 더
for group in calc_sites.GROUPS:
@@ -79,7 +79,6 @@ def test_판_다섯_줄_잠금_기본값_그대로():
"route": (["R1", "R3"], "pair_B5"),
"follow": (["P5", "P6", "F1", "F2"], "pair_B5"),
"stations": (["R5", "R6", "R7", "F3"], "pair_B2"),
"profile": (["P1", "P2", "P3", "P4", "P8"], "pair_B3"),
"cross": (["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], "pair_B4"),
}
for key, (nos, why) in expected.items():
@@ -93,6 +92,7 @@ def test_판_다섯_줄_잠금_기본값_그대로():
"corridor_mesh",
"quantity",
"carry",
"profile", # B-3 엶(511-B · test_511_b3_profile_node)
"fixed_browser",
}
committed = subprocess.run( # noqa: S603 — 고정 실행 파일
@@ -103,7 +103,7 @@ def test_판_다섯_줄_잠금_기본값_그대로():
).stdout
assert calc_sites.PATH.read_bytes().replace(b"\r\n", b"\n") == committed.replace(b"\r\n", b"\n")
locale = (ROOT / "ui_template" / "ui_template_locale_m1.ts").read_text(encoding="utf-8")
for n in (2, 3, 4, 5):
for n in (2, 4, 5):
assert f'B01_CalcSites_Why_pair_B{n}: ["짝 모으는 중 · B-{n}"' in locale
+4 -1
View File
@@ -115,8 +115,11 @@ export const ui_locales_m1 = {
"끄면 잠잠 뒤 서버 셈 · F10 = 브라우저 부름 없음(판만)",
"Off = server calc after a pause · F10 = no browser call",
],
B01_CalcSites_Why_profile: [
"끄면 첫 계획선 · 계획선 짓기를 서버 Node 가 셈(조작 중 왕복)",
"Off = server Node builds the first grade line · grade line (round trip while editing)",
],
B01_CalcSites_Why_pair_B2: ["짝 모으는 중 · B-2", "Merging pairs · B-2"],
B01_CalcSites_Why_pair_B3: ["짝 모으는 중 · B-3", "Merging pairs · B-3"],
B01_CalcSites_Why_pair_B4: ["짝 모으는 중 · B-4", "Merging pairs · B-4"],
B01_CalcSites_Why_pair_B5: ["짝 모으는 중 · B-5", "Merging pairs · B-5"],
B01_CalcSites_Why_form_default: [