feat(365-4): 한 바퀴 정리 — 저장 노선 계획고 = 횡단 한 벌 · B06 다시 그림 한 번

- 저장 노선 cross = 다시 셈 창구 한 벌(refreshCrossDesigns · 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계) — 묶음 cross 가 부르고 그 설계를 냄(detailCross) · 종단도 계획고 편집 뒤 따로 예약 안 함(pipelineOwnsCross)
- B06 다시 그림 — 묶음 차례는 cross 뒤에만 · 겹친 신호(갈아끼움 · 묶음 · 초안)는 refreshDetailForStructures 한 번(최신 차례만)
- 중심점 좌우 = 노선 차례가 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례 · 시험으로 못박음)
- stationDiff → logic/design_pipeline_stations(700줄)
- 시험 test_365(저장 ▲▼ 다시 셈 1 · 상세 설계 그대로 · 중심점 좌우 cross 1) · 346-2 pills · 230-2 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
2026-10-17 12:28:14 +09:00
co-authored by Claude Opus 5.5
parent 495a3cd2d9
commit ec0bb044c2
11 changed files with 192 additions and 42 deletions
+36 -29
View File
@@ -28,9 +28,10 @@ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./log
import { SOURCE_INPUT } from "./logic/design_pipeline_types";
export { pipelineWork } from "./logic/design_pipeline";
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
import { createStationsStage } from "./logic/design_pipeline_stations";
import { createStationsStage, stationDiff } from "./logic/design_pipeline_stations";
import type {
ChangeSource,
CrossOutput,
FollowPatch,
PipelineInputs,
PipelineOutputs,
@@ -147,6 +148,13 @@ export interface PagePipelineOptions {
) => (stations: PipelineStation[]) => Promise<string>;
/** 고리가 `RECONCILE_ROUNDS` 바퀴 안에 안 맞음 — 「계산 불안정」 한 줄(365-2). */
onUnstable?: () => void;
/** 저장 노선 cross = 다시 셈 창구 한 벌(365-4) — 상세 설계를 고치고 그 값을 냄 · 측점이 상세와 다르면 null
* (묶음 엔진). 주면 종단도가 계획고 편집 뒤 따로 다시 셈 안 함(`pipelineOwnsCross`). */
savedCross?: (
detail: SectionDetailResponse,
routeId: number,
stations: ReadonlyArray<PipelineStation>,
) => Promise<CrossOutput | null>;
}
interface PagePipeline {
@@ -157,6 +165,7 @@ interface PagePipeline {
earthwork(context: MassHaulStageContext | null): void;
cross(): void;
reconcile(): Promise<number>;
ownsCross: boolean;
detail(): SectionDetailResponse | null;
dispose(): void;
}
@@ -167,22 +176,6 @@ type Facilities = Pick<DesignStoreValues, "pipes" | "structures">;
const pages = new Map<string, PagePipeline>();
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
function stationDiff(
detail: SectionDetailResponse,
stations: ReadonlyArray<PipelineStation>,
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
const saved = detail.cross_sections.map((section) => section.chainage_m);
const n = Math.max(saved.length, stations.length);
for (let i = 0; i < n; i += 1) {
const pipelineM = stations[i]?.chainageM ?? null;
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
continue;
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
}
return null;
}
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
export function routeDraftBridge(
projectId: string,
@@ -208,6 +201,10 @@ export const pipelineEarthwork = (projectId: string, context: MassHaulStageConte
/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */
export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross();
/** 저장 노선 횡단 다시 셈을 묶음 cross 가 맡는가(365-4) — 종단도는 계획고 편집 뒤 따로 안 셈. */
export const pipelineOwnsCross = (projectId: string): boolean =>
pages.get(projectId)?.ownsCross ?? false;
/** 입력(세션 · 가게) 대 묶음 입력을 견줘 맞춤(365-2) — 돈 바퀴 수(0 = 같음 · -1 = 넘음 · 묶음 없음 = 0). */
export const reconcilePagePipeline = (projectId: string): Promise<number> =>
pages.get(projectId)?.reconcile() ?? Promise.resolve(0);
@@ -363,18 +360,27 @@ function createPagePipeline(
ground: createGroundStage(groundDeps),
planline: createPlanlineStage({ savedBase: () => savedSource().base, savedLine }),
profile: profileStage,
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름
cross: (ctx) =>
crossStage.stage({
...ctx,
inputs: {
...ctx.inputs,
cross: crossWithUphill(
ctx.inputs.cross as CrossInput | null,
ctx.out.ground?.stations ?? [],
),
},
}),
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름 · 저장 노선 = 다시 셈
// 창구 한 벌(카드 · 유토곡선이 보는 상세 설계 · 365-4)
cross: (ctx) => {
const engine = () =>
crossStage.stage({
...ctx,
inputs: {
...ctx.inputs,
cross: crossWithUphill(
ctx.inputs.cross as CrossInput | null,
ctx.out.ground?.stations ?? [],
),
},
});
const { detail = null, routeId } = savedSource();
const saved =
!ctx.inputs.route && detail && typeof routeId === "number"
? options.savedCross?.(detail, routeId, ctx.out.ground?.stations ?? [])
: null;
return saved ? saved.then((out) => out ?? engine()) : engine();
},
footprint: footprintStage,
masshaul: createMassHaulStage(() => earthwork),
haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)),
@@ -671,6 +677,7 @@ function createPagePipeline(
if (cross) void feed("cross", cross);
},
reconcile,
ownsCross: Boolean(options.savedCross),
detail: () => shown,
dispose() {
disposed = true;
+2 -2
View File
@@ -353,8 +353,8 @@ export function createDesignProfile(ctx: DesignProfileContext) {
pagePipeline.pipelineGrade(projectId, store.edits());
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
scheduleCrossPreview();
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음). 묶음이 맡으면 그 cross 한 벌(365-4).
if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview();
drawSoon();
}
/** 다음 틀에 한 번 그림 — 같은 차례 알림 · 갈아끼움 · 설정이 여러 번 불러도 한 번(349-0s). */
+12
View File
@@ -40,6 +40,8 @@ import {
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
import { refreshCrossDesigns } from "../../B06_Section/B06_Section_Cross_Refresh";
import { detailCross } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -210,6 +212,16 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
onCorridorStale: () => each((hooks) => hooks.onCorridorStale?.()),
onFacilitiesFollowed: (far) => each((hooks) => hooks.onFacilitiesFollowed?.(far)),
onUnstable: () => showToast(L("Design_Pipeline_Unstable"), "warning"),
// 저장 노선 횡단 = 다시 셈 창구 한 벌(365-4) — 카드 · 유토곡선 · [저장] 패치가 보는 상세 설계를 고치고 그 값을
// 묶음 출력으로(발자국 · 평면 횡단 판) · 측점이 상세와 다르면(갈아끼움 전) 묶음 엔진
savedCross: async (detail, routeId, stations) => {
if (!detailCross(detail, stations)) return null;
const edits = profile.pipelineSource().edits;
const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits });
if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated);
profile.redraw();
return detailCross(detail, stations);
},
structureAreas: ({ cross_sections: rows }) =>
applyStructureAreaRows(rows, structureAreaRows(rows)),
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
+2 -2
View File
@@ -225,11 +225,11 @@ export async function mountCrossView(
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
try {
await new Promise((next) => setTimeout(next, 0)); // 겹친 신호(갈아끼움 · 묶음 · 초안) = 한 번(365-4)
const fresh = await loadSectionDetail(projectId, currentRouteId);
if (seq !== structuresRefreshSeq) return;
sectionDetail = fresh;
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 계산되므로, 카드 렌더가
// 고치는 것만으로는 곡선이 옛 면적으로 남는다(2026-09-06 실측).
// 면적을 **먼저** 다시 얹는다 — 유토곡선은 카드보다 앞서 셈(카드만 고치면 곡선이 옛 면적 · 2026-09-06 실측).
if (sectionDetail) {
applyStructureAreaRows(
sectionDetail.cross_sections,
@@ -14,7 +14,13 @@ import type {
PlanStructure,
PlanType,
} from "../../../common_util/common_util_structure_station";
import type { PipelineStation, StageContext, StageFn } from "./design_pipeline_types";
import type {
PipelineRunRecord,
PipelineStation,
StageContext,
StageFn,
} from "./design_pipeline_types";
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
/** 범위 밖 측점은 앞 값(같은 열쇠 · 같은 자리)을 그대로 — 뒤 단계가 같은 객체로 「안 바뀜」을 앎. */
export function reuseOutside(
@@ -54,3 +60,19 @@ export function createStationsStage(
return reuseOutside(next, ctx.prev, ctx.range);
};
}
/** 상세 측점 대 묶음 측점(1 mm) — 같으면 null · 다르면 첫 다른 자리 · 개수(묶음 기록 `restoreDiff`). */
export function stationDiff(
detail: SectionDetailResponse,
stations: ReadonlyArray<PipelineStation>,
): NonNullable<PipelineRunRecord["restoreDiff"]> | null {
const saved = detail.cross_sections.map((section) => section.chainage_m);
const n = Math.max(saved.length, stations.length);
for (let i = 0; i < n; i += 1) {
const pipelineM = stations[i]?.chainageM ?? null;
if (saved[i] !== undefined && pipelineM !== null && Math.abs(saved[i] - pipelineM) < 1e-3)
continue;
return { savedM: saved[i] ?? null, pipelineM, saved: saved.length, pipeline: stations.length };
}
return null;
}
@@ -102,6 +102,33 @@ function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs {
return out as SectionSpecs;
}
/** 저장 상세 → 묶음 cross 출력(365-4 · 저장 노선 = 다시 셈 창구 한 벌이 고친 설계 그대로) — 측점이 상세와
* 다르면(갈아끼움 전) null. */
export function detailCross(
detail: SectionDetailResponse,
stations: ReadonlyArray<PipelineStation>,
): CrossOutput | null {
const sections = detail.cross_sections;
const same =
sections.length === stations.length &&
stations.every((station, i) => Math.abs(station.chainageM - sections[i].chainage_m) < 1e-3);
if (!same) return null;
const byKey: CrossOutput["byKey"] = {};
stations.forEach((station, i) => {
const { design, samples } = sections[i];
if (!design) return;
byKey[station.key] = {
key: station.key,
chainageM: station.chainageM,
cutAreaM2: design.cut_area_m2,
fillAreaM2: design.fill_area_m2,
detail: design,
...(samples ? { samples } : {}),
} as CrossOutput["byKey"][string];
});
return { byKey };
}
/** 묶음 측점 하나 → 횡단(머리 · 지반 표본 · 설계) — 새 노선 상세 · B05 오른쪽 횡단 판(365-3)이 같은 값. */
export function stationSection(
station: PipelineStation,
@@ -6,6 +6,8 @@
* 종단 보기도 같은 가게를 듣고 같은 함수를 부름 — 먼저 부른 쪽이 갈아끼우고 다음 쪽은 「같은 목록」.
* 노선 초안 동안(346-2 덧 3) 카드 = 묶음 새 노선 상세(`pipelineDetail` · cross 단계 결과) — 새 상세가 오면
* 같은 길로 다시 그림 · 초안을 버리면(상세 null) 저장 노선 카드로.
* 묶음 차례는 cross 뒤에만 다시 그림(profile 뒤 중간 상세는 안 그림 · 365-4) — 겹친 신호(갈아끼움 · 묶음 · 초안)는
* 횡단 보기 다시 읽기가 한 번으로 묶음(`design_view_cross` refreshDetailForStructures · 최신 차례만).
* ========================================================================== */
import { useDesignStore } from "../A00_Common/design/design_store";
@@ -42,7 +44,7 @@ export function followSwaps(
};
}
/** 묶음 새 노선 상세가 바뀌면(profile · cross 단계 뒤) 부름 — 묶음은 종단 보기가 세우므로 차례 번호가 날 때 붙음. */
/** 묶음 새 노선 상세가 바뀌면(cross 단계 뒤) 부름 — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음. */
function followDraftDetail(projectId: string, changed: () => void): () => void {
let pipe: DesignPipeline | null = null;
let stopPipe = (): void => undefined;
@@ -54,8 +56,7 @@ function followDraftDetail(projectId: string, changed: () => void): () => void {
pipe = now;
stopPipe =
now?.subscribe((event) => {
if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross"))
return;
if (event.kind !== "stage" || event.stage !== "cross") return;
const next = pipelineDetail(projectId);
if (next === drawn) return;
drawn = next;
@@ -99,7 +99,7 @@ out.viewOf = {
buildAlignment: () => null,
store: { edits: () => ({}) },
projectId: "p",
pagePipeline: { pipelineGrade: () => undefined }, // 계산 묶음(346-1) — 묶음 없으면 아무 일 없음
pagePipeline: { pipelineGrade: () => undefined, pipelineOwnsCross: () => false }, // 계산 묶음(346-1 · 365-4) — 묶음 없으면 아무 일 없음
scheduleCrossPreview: () => s.previews++,
draw: () => s.draws++,
root: inPopup(),
+54 -1
View File
@@ -336,7 +336,60 @@ async function run() {
};
pageMod.disposePagePipeline(P);
page.clearDrafts(P);
return { kept, b05, b06, dropped, edit, loop, pane: paneCheck };
// ⑨ 저장 노선 ▲▼ 한 번 = 횡단 한 벌(365-4) — 묶음 cross 가 다시 셈 창구를 부르고 그 상세 설계를 냄
const Q = "proj-365b";
window.localStorage.setItem("frd_current_project_id", Q);
let refreshCalls = 0;
const qBridge = pageMod.routeDraftBridge(Q, link(), {
stationIntervalM: job.interval,
savedCross: async (detail, _routeId, stations) => {
if (!detailMod.detailCross(detail, stations)) return null;
refreshCalls += 1;
return detailMod.detailCross(detail, stations);
},
});
pageMod.pipelineEarthwork(Q, { conversion: job.conversion });
const qPipe = pageMod.pagePipeline(Q);
await settle(runs().length + 1);
await tick(20);
const openCalls = refreshCalls;
const key = alignment.chainageKey(savedBase.stations[3].chainage_m);
mark = runs().length;
pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.3 }, curve_radii: {} });
const gradeRun = await settle(mark + 1);
const firstKey = qPipe.output("ground").stations[0].key;
const saved = {
owns: pageMod.pipelineOwnsCross(Q),
openCalls,
gradeCalls: refreshCalls - openCalls,
runs: runs().length - mark,
sources: gradeRun.sources,
fromDetail:
qPipe.output("cross").byKey[firstKey]?.detail === job.detail.cross_sections[0].design,
};
// ⑧ 중심점 좌우(356-3 꼴) — 노선 한 걸음 → 새 종단이 오면 계획고 맞춤 · 첫 차례는 cross 전에 밀림(무거운 셈 한 번)
let graded = false;
const stopShift = qPipe.subscribe((event) => {
if (event.kind !== "stage" || event.stage !== "profile" || graded) return;
if (event.generation !== qPipe.generation) return;
graded = true;
void Promise.resolve().then(() =>
pageMod.pipelineGrade(Q, { station_offsets: { [key]: 0.1 }, curve_radii: {} }),
);
}, "중심점 좌우");
mark = runs().length;
qBridge.edited(draftOf(shift(1, 0, 1)));
await settle(mark + 2);
await tick(job.haulDelayMs + 30);
stopShift();
const shiftRuns = runs()
.slice(mark)
.map((r) => ({ outcome: r.outcome, cross: "cross" in r.stages, sources: r.sources }));
pageMod.disposePagePipeline(Q);
page.clearDrafts(Q);
return { kept, b05, b06, dropped, edit, loop, pane: paneCheck, saved, shiftRuns };
}
run()
@@ -38,20 +38,20 @@ def out():
def test_노선_초안_동안_카드는_새_노선_상세(out):
cards = out["cards"]
assert cards["before"] == "saved"
assert cards["afterDraft"] == ["draft-profile-1", "draft-cross-1"]
assert cards["afterDraft"] == ["draft-cross-1"] # profile 차례 중간 상세는 안 그림(365-4)
assert cards["draftNow"] == "draft-cross-1"
def test_숨은_동안은_안_그림(out):
cards = out["cards"]
assert cards["whileHidden"] == ["draft-profile-1", "draft-cross-1"]
assert cards["whileHidden"] == ["draft-cross-1"]
assert cards["hiddenNow"] == "draft-cross-2"
def test_초안_버리면_저장_노선_카드(out):
cards = out["cards"]
assert cards["discarded"][-1] == "saved" and cards["savedNow"] == "saved"
assert cards["afterStop"] == 3 # 끊은 뒤엔 안 그림
assert cards["afterStop"] == 2 # 끊은 뒤엔 안 그림
def test_알약은_따라간_누가거리(out):
@@ -10,6 +10,10 @@
- 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜 = 1 바퀴 · 안 맞으면 3 바퀴 뒤 「계산 불안정」 한 번.
못박는 것(365-3)
- B05 오른쪽 횡단 판 = 묶음 그 측점 — 계획고 · 절토 면적 · 표본 = B06 카드 · 서버 길 없음.
못박는 것(365-4)
- 저장 노선 ▲▼ 한 번 = 횡단 한 벌 — 묶음 cross 가 다시 셈 창구(상세 설계) 한 번 · 종단도 예약 끔.
- 중심점 좌우 = 노선 차례는 cross 전에 밀림 → cross 한 번(계획고 맞춤 차례만).
- B06 다시 그림 = 겹친 신호(갈아끼움 · 묶음 cross · 초안) 한 번 · profile 차례는 안 그림.
짝: `helper_365_page_pipeline.cjs`(진짜 TS · 서버 창구만 가짜 · 틀 = 346-2 시험) ·
`helper_365_swap_entry.ts` + `helper_365_swap.mjs`(진짜 상세 가게 · vite ssr).
@@ -132,6 +136,30 @@ def test_365_3_서버_횡단_미리보기_길_없음():
assert "crossPreview.refresh()" not in edit # 묶음 cross 차례가 그림
def test_365_4_저장_노선_계획고_횡단_한_벌(r):
s = r["saved"]
assert s["owns"] and s["openCalls"] == 1
assert s["gradeCalls"] == 1 and s["runs"] == 1 and s["sources"] == ["grade"]
assert s["fromDetail"] # 묶음 cross = 상세 설계 그대로(엔진 따로 안 셈)
def test_365_4_중심점_좌우_횡단_한_번(r):
runs = r["shiftRuns"]
assert [x["outcome"] for x in runs] == ["dropped", "done"], runs
assert [x["cross"] for x in runs] == [False, True]
assert runs[1]["sources"] == ["route", "grade"] # 밀린 노선 범위 + 계획고 = 한 차례
def test_365_4_다시_그림_한_번():
swap = (ROOT / "B06_Section/B06_Section_UI_Page_Station_Swap.ts").read_text(encoding="utf-8")
view = (ROOT / "A00_Common/design/design_view_cross.ts").read_text(encoding="utf-8")
profile = (ROOT / "A00_Common/design/design_profile.ts").read_text(encoding="utf-8")
assert 'event.stage !== "cross") return;' in swap # profile 차례 중간 상세는 안 그림
body = view.split("async function refreshDetailForStructures")[1].split("loadSectionDetail")[0]
assert "setTimeout(next, 0)" in body # 겹친 신호는 seq 가 앞 차례를 버림 = 한 번
assert "if (!pagePipeline.pipelineOwnsCross(projectId)) scheduleCrossPreview();" in profile
@pytest.fixture(scope="module")
def swap(tmp_path_factory) -> dict:
work = tmp_path_factory.mktemp("b365")