From e043aee953c6f29354600c9a508a064d59b42387 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 22 Oct 2026 14:26:11 +0900 Subject: [PATCH] =?UTF-8?q?feat(511-B=20B-3=20=ED=8C=90=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=EC=A4=84=20=EC=97=B6):=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20profile=20=EC=A4=84=20=3D=20=EA=B3=A0?= =?UTF-8?q?=EB=A5=BC=20=EC=88=98=20=EC=9E=88=EC=9D=8C(=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=EA=B0=92=20both=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=ED=8C=90=20=ED=8C=8C=EC=9D=BC=20profile=20both)=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EC=84=9C=EB=B2=84=E3=80=8D=20=3D=20=EB=AC=B6=EC=9D=8C?= =?UTF-8?q?=20planline=20=EC=83=88=20=EC=A7=80=EB=B0=98=20=EC=B2=AB=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D=EC=84=A0=20=EA=BC=AD=EC=A7=93=EC=A0=90(profi?= =?UTF-8?q?le.base)=20=C2=B7=20profile=20=EB=8B=A8=EA=B3=84=20=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=A0=20=EC=A7=93=EA=B8=B0(profile.alignment)?= =?UTF-8?q?=EB=A5=BC=20=EC=B0=BD=EA=B5=AC=20=E2=86=92=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20Node=20=EA=B0=99=EC=9D=80=20TS(design=5Fcalc=5Fprofile.ts)?= =?UTF-8?q?=20=C2=B7=20both=20=3D=20=EB=8B=A8=EA=B3=84=20=EB=8F=99?= =?UTF-8?q?=EA=B8=B0=20=EA=B7=B8=EB=8C=80=EB=A1=9C(=EB=AC=B6=EC=9D=8C=20?= =?UTF-8?q?=EC=B0=A8=EB=A1=80=20=EB=B0=94=EB=80=9C=200)=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=8B=A4=ED=8C=A8=20=3D=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=A0=20=C2=B7=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=EA=B9=8C=EB=8B=AD=20=EA=B8=80=20pair=5FB3?= =?UTF-8?q?=20=E2=86=92=20=EB=81=84=EB=A9=B4=20=EC=84=9C=EB=B2=84=20Node?= =?UTF-8?q?=20=E2=80=94=20=EC=9A=94=EA=B5=AC=20320-4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/design/design_calc_profile.ts | 91 +++++++++++++++++++ A00_Common/design/design_pipeline_page.ts | 5 +- .../logic/B05_Profile_Pipeline_Grade.ts | 78 +++++++++------- common_util/common_util_calc_sites.py | 7 +- resources/master_template/calc_sites.json | 1 + resources/tester/test_309_2_calc_sites.py | 1 + .../tester/test_511_b1_calc_node_stages.py | 4 +- ui_template/ui_template_locale_m1.ts | 5 +- 8 files changed, 150 insertions(+), 42 deletions(-) create mode 100644 A00_Common/design/design_calc_profile.ts diff --git a/A00_Common/design/design_calc_profile.ts b/A00_Common/design/design_calc_profile.ts new file mode 100644 index 000000000..2a5ce57f3 --- /dev/null +++ b/A00_Common/design/design_calc_profile.ts @@ -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(signal: AbortSignal, remote: () => Promise, 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 | 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, + ); +}; diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 7812afd9b..111a132a0 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -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) => { diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts index b7084ba0d..c2e79a615 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Grade.ts @@ -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): 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 | 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) ?? []) : []); }; } diff --git a/common_util/common_util_calc_sites.py b/common_util/common_util_calc_sites.py index 789e460df..a431c0dfc 100644 --- a/common_util/common_util_calc_sites.py +++ b/common_util/common_util_calc_sites.py @@ -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", diff --git a/resources/master_template/calc_sites.json b/resources/master_template/calc_sites.json index c0ad2f7a9..0b5b49a49 100644 --- a/resources/master_template/calc_sites.json +++ b/resources/master_template/calc_sites.json @@ -5,6 +5,7 @@ "corridor_mesh": "both", "quantity": "both", "carry": "both", + "profile": "both", "fixed_browser": "both" } } diff --git a/resources/tester/test_309_2_calc_sites.py b/resources/tester/test_309_2_calc_sites.py index 1e9b60dcd..453a2947a 100644 --- a/resources/tester/test_309_2_calc_sites.py +++ b/resources/tester/test_309_2_calc_sites.py @@ -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: diff --git a/resources/tester/test_511_b1_calc_node_stages.py b/resources/tester/test_511_b1_calc_node_stages.py index 619e3556c..593c9a4b0 100644 --- a/resources/tester/test_511_b1_calc_node_stages.py +++ b/resources/tester/test_511_b1_calc_node_stages.py @@ -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 diff --git a/ui_template/ui_template_locale_m1.ts b/ui_template/ui_template_locale_m1.ts index cb45089fc..e76e47eca 100644 --- a/ui_template/ui_template_locale_m1.ts +++ b/ui_template/ui_template_locale_m1.ts @@ -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: [