Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1

# Conflicts:
#	B05_Profile/B05_Profile_UI_RouteEdit.ts
#	ui_template/ui_template_locale_b2.ts
This commit is contained in:
2026-10-17 12:29:44 +09:00
23 changed files with 604 additions and 376 deletions
+2
View File
@@ -32,6 +32,7 @@ import { saveSharedDesign, type DesignPageShared } from "./design_shared";
import { guardDesignLeave } from "./design_leave";
import { usePipeIds } from "./design_pipe_ids";
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
import { reconcilePagePipeline } from "./design_pipeline_page";
import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
@@ -244,6 +245,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
if (slot) {
slot.show();
applyView(view);
void reconcilePagePipeline(projectId); // 들어올 때 입력(세션 · 가게) 대 묶음 맞춤(365-2)
} else mountView(view, true);
// 검증용 — 보기 전환에 든 시간(ms)과 횟수를 화면 밖에서 수치로 본다.
const probe = window as unknown as { __designPage?: { view: string; switchMs: number[] } };
+41 -2
View File
@@ -8,9 +8,11 @@
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
* 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구
* 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계(세션 고침은 종단도 · 횡단 보기가 다시 셈하며 이미 얹음).
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 ·
* 암 경사) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2). 부를 때마다 모음.
* ========================================================================== */
import { readState } from "../b_page_state";
import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import {
effectiveStandardCross,
@@ -71,11 +73,45 @@ function carried(section: CrossSection): Record<string, unknown> {
};
}
/** 저장 상세 + 저장 노선 측점(열쇠) → cross 입력 · 시설 제원 — 표준단면 · 상세가 없으면 null. */
/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */
function sessionCells(
name: "crossdesign" | "rockb" | "cutslope",
projectId: string,
routeId: number,
): Array<[string, unknown]> {
const raw = readState<Record<string, unknown>>(name, projectId, routeId);
if (!raw || typeof raw !== "object") return [];
return Object.entries(raw).filter(([key]) => key.includes(":") && !key.startsWith("at:"));
}
/** 세션 횡단 고침 → 측점 열쇠마다 넘기는 입력 조각(`carried` 와 같은 칸 이름). */
function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"] {
const out: CrossInput["byKey"] = {};
for (const [key, value] of sessionCells("crossdesign", projectId, routeId)) {
const choice = (value ?? {}) as Record<string, unknown>;
const entry = (out[key] ??= {});
if (typeof choice.ground_type === "string") entry.groundType = choice.ground_type;
if (typeof choice.ditch_type === "string") entry.ditchType = choice.ditch_type;
if ("ditch_choice" in choice)
entry.ditchChoice = typeof choice.ditch_choice === "boolean" ? choice.ditch_choice : null;
if ("two_stage_slope" in choice) entry.twoStageSlope = Boolean(choice.two_stage_slope);
}
for (const [key, value] of sessionCells("rockb", projectId, routeId))
if (finite(value) !== null) (out[key] ??= {}).rockBoundaryOffsetM = value;
for (const [key, value] of sessionCells("cutslope", projectId, routeId)) {
const ratio = finite(value);
if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null;
}
return out;
}
/** 저장 상세 + 저장 노선 측점(열쇠) + 세션 고침(`routeId` 칸) → cross 입력 · 시설 제원 — 표준단면 ·
* 상세가 없으면 null. */
export function pipelineCrossSource(
projectId: string,
detail: SectionDetailResponse | null,
savedStations: ReadonlyArray<PipelineStation>,
routeId: number | null = null,
): PipelineCrossSource | null {
const standard = detail && effectiveStandardCross(projectId);
if (!detail || !standard) return null;
@@ -94,6 +130,9 @@ export function pipelineCrossSource(
for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name];
specs.set(best.key, own as SectionSpecs);
}
if (routeId !== null)
for (const [key, entry] of Object.entries(sessionCarried(projectId, routeId)))
byKey[key] = { ...byKey[key], ...entry };
return {
// 암 경계 기본값 = 서버 config(화면이 문맥에서 기억) — 입력 없는 측점(초안 새 측점)도 서버처럼(349-0p)
input: { standard, byKey, rockBoundaryDefaultM: readRockBoundaryDefault(projectId) },
+117 -48
View File
@@ -12,7 +12,10 @@
*
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
* 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
* 입력 + 세션 고침(`design_pipeline_cross.ts` · 부를 때마다 모음 · 횡단 고침 뒤 `pipelineCross` · 365-2) ·
* 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
* 고리(365-2) — 열 때 · 페이지 들어올 때(`reconcilePagePipeline`) 입력(세션 · 가게)을 묶음이 쥔 입력과 견줌 →
* 다르면 한 바퀴 → 끝난 뒤 다시 견줌 · 최대 `RECONCILE_ROUNDS` · 넘으면 `onUnstable`(「계산 불안정」 한 줄).
* 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 ·
* 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림.
* 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선).
@@ -22,15 +25,19 @@
* ========================================================================== */
import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline";
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,
PipelineRunRecord,
PipelineStation,
RangePart,
RouteDraft,
StageFn,
Vertex,
@@ -102,6 +109,8 @@ import type {
/** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */
export const DRAFT_ROUTE_ID = 0;
/** 입력 ↔ 묶음 입력 맞춤 상한(바퀴) — 넘으면 「계산 불안정」(365-2). */
export const RECONCILE_ROUNDS = 3;
/** 종단도(`design_profile`)가 묶음에 내주는 것. */
export interface ProfileLink {
@@ -137,6 +146,15 @@ export interface PagePipelineOptions {
routeId: number,
saved: SectionDetailResponse | null,
) => (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 {
@@ -145,6 +163,9 @@ interface PagePipeline {
grade(edits: AlignmentEdits): void;
restart(): void;
earthwork(context: MassHaulStageContext | null): void;
cross(): void;
reconcile(): Promise<number>;
ownsCross: boolean;
detail(): SectionDetailResponse | null;
dispose(): void;
}
@@ -155,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,
@@ -193,6 +198,17 @@ export const restartPagePipeline = (projectId: string): void => pages.get(projec
export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void =>
pages.get(projectId)?.earthwork(context);
/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — 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);
/** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */
export const pipelineDetail = (projectId: string): SectionDetailResponse | null =>
pages.get(projectId)?.detail() ?? null;
@@ -219,10 +235,9 @@ function createPagePipeline(
let savedStructures: PlanStructure[] = [];
let savedPipes: PlanPipe[] = [];
let earthwork: MassHaulStageContext | null = null;
/** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */
/** 저장 노선 측점(열쇠) · 마지막으로 모은 횡단 입력(시설 제원 — 새 노선 상세가 씀). */
let savedStations: PipelineStation[] = [];
let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null =
null;
let crossSource: PipelineCrossSource | null = null;
/** 저장 정본(서버가 준 상세 · 노선 번호) — 측점 갈아끼움(swap)이 만든 새 상세는 지반 표본 배열을
* 그대로 둠 → 정본 아님(묶음 출력 → 상세 → 다시 묶음 고리를 끊음). */
let canon: { routeId: unknown; samples: unknown; detail: SectionDetailResponse | null } | null =
@@ -286,12 +301,17 @@ function createPagePipeline(
emptyEdits()
);
};
/** 횡단 입력 — 부를 때마다 저장 상세 + 세션 고침 · 표준을 모음(상세 객체마다 기억하면 고침이 안 감 · 365-2). */
const crossNow = (): PipelineCrossSource | null => {
const detail = canon?.detail ?? null;
if (!detail) return null;
if (crossSource?.detail !== detail || !crossSource.source)
crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) };
return crossSource.source;
const routeId = canon?.routeId;
const source = pipelineCrossSource(
projectId,
canon?.detail ?? null,
savedStations,
typeof routeId === "number" ? routeId : null,
);
if (source) crossSource = source;
return source;
};
const groundDeps = groundStageDeps(projectId);
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
@@ -340,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)),
@@ -460,7 +489,7 @@ function createPagePipeline(
ground,
profile,
pipe.output("cross"),
crossSource?.source?.specs ?? new Map(),
crossSource?.specs ?? new Map(),
options.stationIntervalM ?? profile.policy.station_interval_m,
)
: null;
@@ -517,11 +546,48 @@ function createPagePipeline(
// 초안 둔 채 열기(새로고침 · B06 바로 열기 · 365-1) — 초안 전 = 지금 저장 상세(세션 그대로) · 측점은 가게에 냄
held = inputs.route ? (held ?? holdNow()) : null;
dropping = null;
void pipe.start(inputs);
Object.assign(given, inputs);
const run = (lastRun = pipe.start(inputs));
openGeneration = pipe.generation;
quietGeneration = inputs.route ? -1 : pipe.generation;
void run.then(() => reconcile()); // 열 때 셈하는 사이 바뀐 입력(세션 · 가게)도 맞춤
};
/** 묶음에 넘긴 입력(follow 가 고치기 전) · 마지막으로 띄운 차례 — 고리가 견줌 · 기다림. */
const given: Partial<PipelineInputs> = {};
let lastRun: Promise<unknown> = Promise.resolve();
const feed = (source: ChangeSource, value: unknown, part: RangePart | null = null) => {
given[SOURCE_INPUT[source]] = value as never;
const run = pipe.update(source, value, part);
if (run) lastRun = run;
return run;
};
let reconciling: Promise<number> | null = null;
/** 입력 지문 대 묶음 입력 — 넘긴 값 · follow 뒤 값 둘 다와 다르면 한 바퀴(노선 = 다리 길) · 끝난 뒤 다시 견줌. */
function reconcile(): Promise<number> {
if (!started || disposed) return Promise.resolve(0);
return (reconciling ??= (async () => {
for (let round = 0; ; round += 1) {
let seen: Promise<unknown> | null = null;
while (seen !== lastRun) await (seen = lastRun); // 기다리는 사이 뜬 차례까지
await pipe.settled();
if (disposed || canon?.routeId !== savedSource().routeId) return round; // 정본 바뀜 = restart 몫
const now = inputsNow();
const stale = (Object.entries(SOURCE_INPUT) as Array<[ChangeSource, keyof PipelineInputs]>)
.filter(([, key]) => !same(now[key], pipe.input(key)) && !same(now[key], given[key]))
.map(([source, key]): [ChangeSource, unknown] => [source, now[key]]);
if (!stale.length) return round;
if (round >= RECONCILE_ROUNDS) {
options.onUnstable?.();
return -1;
}
for (const [source, value] of stale)
if (source === "route") bridge.edited(value as RouteDraft | null);
else feed(source, value);
}
})().finally(() => (reconciling = null)));
}
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */
const loadPlan = (): Promise<void> =>
fetchRoutePlan(projectId)
@@ -555,13 +621,10 @@ function createPagePipeline(
.catch(() => undefined);
const unsubscribe = [
store.subscribe(
"pipes",
(pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes),
),
store.subscribe("pipes", (pipes) => started && !echo && void feed("pipe", pipes ?? savedPipes)),
store.subscribe(
"structures",
(list) => started && !echo && void pipe.update("structure", list ?? savedStructures),
(list) => started && !echo && void feed("structure", list ?? savedStructures),
),
];
@@ -582,7 +645,7 @@ function createPagePipeline(
if (!started) return start();
const before = pipe.output("route")?.line ?? null;
const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine();
const run = pipe.update("route", draft, routeRange(before, after));
const run = feed("route", draft, routeRange(before, after));
if (run && !draft && wasDraft) {
quietGeneration = pipe.generation;
dropping = run.then(finishDrop);
@@ -598,7 +661,7 @@ function createPagePipeline(
if (same(edits, pipe.input("alignment"))) return;
const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? [];
const before = pipe.input("alignment") as AlignmentEdits | null;
void pipe.update("grade", edits, gradeRange(before, edits, nodes));
void feed("grade", edits, gradeRange(before, edits, nodes));
},
restart() {
if (!started) return start();
@@ -607,8 +670,14 @@ function createPagePipeline(
earthwork(context) {
earthwork = context;
const cross = crossNow()?.input ?? null;
if (started && cross) void pipe.update("cross", cross);
if (started && cross) void feed("cross", cross);
},
cross() {
const cross = started ? (crossNow()?.input ?? null) : null;
if (cross) void feed("cross", cross);
},
reconcile,
ownsCross: Boolean(options.savedCross),
detail: () => shown,
dispose() {
disposed = true;
+2 -2
View File
@@ -354,8 +354,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). */
+13
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];
@@ -209,6 +211,17 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
stationIntervalM: SECTION_STATION_INTERVAL_M,
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;
}
+2 -28
View File
@@ -229,7 +229,7 @@ export async function fetchRouteElevations(
return payload.z;
}
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
/** 횡단 미리보기 한 장(평면 오른쪽 판) — 계산 묶음 그 측점을 이 꼴로 그림(365-3 · 서버 길 없음). */
export interface CrossPreviewResponse {
status: string;
chainage_m: number;
@@ -241,7 +241,7 @@ export interface CrossPreviewResponse {
cross_half_width_m: number;
/** 원지반 횡단 샘플. */
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
/** 계획 횡단 — 묶음 cross 단계 설계(B06 카드와 같음). 계획고를 못 세우면 null. */
design: {
design_line: Array<{ offset_m: number; elevation_m: number }>;
cut_area_m2: number;
@@ -250,32 +250,6 @@ export interface CrossPreviewResponse {
} | null;
}
export interface CrossPreviewRequest {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
chainage_m: number;
min_radius_m: number;
station_interval_m: number;
/** 횡단 반폭(m) — 없으면 서버 기본값. */
cross_half_width_m?: number;
/** [측점 스냅] 켬 — 호 안 측점 하나를 곡선 중점으로(요구 224-2). */
station_snap?: boolean;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
export async function fetchCrossPreview(
projectId: string,
request: CrossPreviewRequest,
): Promise<CrossPreviewResponse> {
return requestJson<CrossPreviewResponse>(
`/projects/${projectId}/route/cross-preview`,
{
method: "POST",
body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }),
},
120000,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
+2 -146
View File
@@ -1,15 +1,14 @@
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 두 점을 찍어
**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)와 한 측점의 **횡단도 미리보기**(⑧)는 지반고가
있어야 한다. 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지
**구간 길이와 종단기울기**를 볼 때(0-9 ⑤)는 지반고가 있어야 한다(측점 횡단 미리보기 = 계산
묶음 · 365-3). 새 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 그 규칙과 부딪히지
않는다 — 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다.
두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기
GET /api/projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag)
@@ -30,13 +29,7 @@ from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import (
AnalysisTerrain,
@@ -121,143 +114,6 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) ->
}
class PreviewVertex(BaseModel):
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
x: float
y: float
curve: bool = True
radius_m: float | None = None
class CrossPreviewRequest(BaseModel):
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
vertices: list[PreviewVertex] = Field(..., min_length=2)
chainage_m: float = Field(..., ge=0)
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
min_radius_m: float = Field(12.0, gt=0)
station_interval_m: float | None = None
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
cross_half_width_m: float | None = Field(None, gt=0)
terrain: AnalysisTerrain | None = None
#: [측점 스냅] 켬(요구 224-2) — 편집 창 단추 값 그대로.
station_snap: bool = False
def _cross_preview(
project_root: Path,
params: dict,
request: CrossPreviewRequest,
) -> dict | None:
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
**B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에
존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design`
이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지
않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면
기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다.
"""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
return None
built = build_planned_polyline(
[(vertex.x, vertex.y) for vertex in request.vertices],
min_radius_m=request.min_radius_m,
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
simplify=False,
curve_flags=[vertex.curve for vertex in request.vertices],
radii=[vertex.radius_m for vertex in request.vertices],
)
interval = request.station_interval_m
defaults = SectionGenerationOptions()
options = SectionGenerationOptions(
station_interval_m=float(interval)
if interval and interval > 0
else defaults.station_interval_m,
cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m,
)
# 곡선표 = 방금 지은 노선의 곡선 — 스냅 자리 · 확폭이 [확인] 뒤 서버 정본과 같은 규칙.
result = generate_sections(
built.vertices,
sampler,
options,
design_curves=[curve.as_dict() for curve in built.curves],
station_snap=request.station_snap,
)
sections = result["cross_sections"]
if not sections:
return None
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
design = None
center_z = section.get("center_z")
if center_z is not None:
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
design = compute_cross_design(
section["samples"],
float(center_z),
ground_type="soil",
section_mode=section_mode,
**curve_widening_args(section),
)
return {
"chainage_m": round(float(section["chainage_m"]), 3),
"label": section.get("label"),
"uphill_side": section.get("uphill_side"),
"plan_radius_m": section.get("plan_radius_m"),
"curve_widening_m": section.get("curve_widening_m"),
# 샘플이 실제로 덮은 반폭 — 평면도 반폭 선이 이 값을 그대로 쓴다.
"cross_half_width_m": options.cross_half_width_m,
"samples": section["samples"],
"design": design,
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
if result.get("longitudinal", {}).get("total_length_m") is not None
else None,
}
@router.post("/{project_id}/route/cross-preview", response_model=None)
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
params = analysis_terrain_params(params, request.terrain)
project_root = Path(resolve_stored_project_path(stored))
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
if preview is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
},
)
return {"status": "success", "project_id": str(project_id), **preview}
@router.get("/{project_id}/route/terrain-sources", response_model=None)
async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
"""편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김."""
-2
View File
@@ -267,7 +267,6 @@ export async function mountRouteEditor(
stationSnapButton.addEventListener("click", () => {
setStationSnap(!stationSnap);
if (stationSnap && history) applyEdit("곡선 가운데(MC)를 일반측점에 맞췄습니다."); // 전체 · 한 걸음
void crossPreview.refresh(); // 떠 있는 횡단도 옮긴 측점 규칙으로.
draw();
});
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
@@ -684,7 +683,6 @@ export async function mountRouteEditor(
useBackground(background);
measure.clear();
slope.refresh();
void crossPreview.refresh();
showStatus();
draw();
});
+82 -38
View File
@@ -2,22 +2,24 @@
* B05_Profile_UI_RouteEdit_Cross.ts
* 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲).
*
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다.
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점 횡단을 여기에 낸다.
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
* 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다.
* 위 판의 것이 이리로 내려오고 새 횡단이 위로 올라간다 — 전후를 나란히 본다.
*
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 계획 횡단선 ·
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
*
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다.
*
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
* 값 = 계산 묶음 그 측점(365-3) — 측점 · 지반 표본 = ground 단계 · 계획고 · 설계선 = 종단 → cross 단계
* (B06 카드 · 새 노선 상세와 같은 함수 `stationSection`) · 노선 · 계획고가 바뀔 때마다(cross 차례) 다시 그림 ·
* 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
* ========================================================================== */
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import { pagePipeline } from "../A00_Common/design/design_pipeline_page";
import type { DesignPipeline } from "../A00_Common/design/logic/design_pipeline";
import { useDesignStore } from "../A00_Common/design/design_store";
import { stationSection } from "./logic/B05_Profile_Pipeline_Detail";
import {
buildCrossSvg,
CROSS_PLOT_PAD,
@@ -30,7 +32,7 @@ export interface CrossPreviewParams {
projectId: string;
/** 두 판이 들어앉을 오른쪽 세로 칸. */
side: HTMLElement;
/** 지금 편집값 — 셈을 부르는 순간에 읽는다. */
/** 지금 편집값 — 측점 표기 간격만 씀(횡단은 계산 묶음 몫 · 365-3). */
request: () => {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number;
@@ -51,7 +53,7 @@ export interface CrossWatch {
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
open: (chainageM: number) => Promise<void>;
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
/** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
/** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */
watched: () => CrossWatch | null;
@@ -158,54 +160,96 @@ function createPane(title: string, empty: string): CrossPane {
};
}
/** 묶음 출력에서 그 누가거리에 가장 가까운 측점 횡단 — 묶음이 아직 안 셌으면 null. */
export function pipelineCrossAt(
pipe: DesignPipeline | null,
chainageM: number,
intervalM: number,
halfWidthM: number | undefined,
): CrossPreviewResponse | null {
const stations = pipe?.output("ground")?.stations ?? [];
if (!pipe || !stations.length) return null;
const station = stations.reduce((best, next) =>
Math.abs(next.chainageM - chainageM) < Math.abs(best.chainageM - chainageM) ? next : best,
);
const section = stationSection(station, pipe.output("cross"), {}, intervalM);
const reach = Math.max(0, ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)));
return {
status: "success",
chainage_m: section.chainage_m,
label: section.label ?? null,
uphill_side: section.uphill_side ?? null,
plan_radius_m: section.plan_radius_m ?? null,
curve_widening_m: section.curve_widening_m ?? null,
cross_half_width_m: halfWidthM ?? reach,
samples: section.samples,
design: (section.design as CrossPreviewResponse["design"] | undefined) ?? null,
};
}
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다.");
const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다.");
params.side.append(current.root, previous.root);
const interval = (): number => params.request().station_interval_m;
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
let watching: number | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */
let shown: CrossPreviewResponse | null = null;
/** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */
let ticket = 0;
let drawnRoute: unknown = null;
async function load(chainageM: number, keepPrevious: boolean): Promise<void> {
const mine = ++ticket;
const request = params.request();
if (keepPrevious && shown) previous.show(shown, request.station_interval_m);
current.wait("읽는 중…");
try {
const preview = await fetchCrossPreview(params.projectId, {
...request,
chainage_m: chainageM,
cross_half_width_m: appliedHalfWidth(params.projectId),
});
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
shown = preview;
current.show(preview, request.station_interval_m);
} catch (error) {
if (mine !== ticket) return;
shown = null;
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
}
function redraw(): void {
if (watching === null) return;
const route = pipe?.input("route") ?? null;
if (route !== drawnRoute && shown) previous.show(shown, interval());
drawnRoute = route;
shown = pipelineCrossAt(pipe, watching, interval(), appliedHalfWidth(params.projectId));
if (shown) current.show(shown, interval());
else current.wait("계산 중…");
params.onShown?.();
}
// 묶음 cross 차례마다 다시 그림(노선 · 계획고) — 묶음은 페이지가 세우므로 차례 번호가 날 때 붙음
let pipe: DesignPipeline | null = null;
let stopPipe = (): void => undefined;
let connected = false;
const attach = (): void => {
if (current.root.isConnected) connected = true;
else if (connected) return stop(); // 편집 창이 닫혔다
const now = pagePipeline(params.projectId);
if (now === pipe) return;
stopPipe();
pipe = now;
stopPipe =
now?.subscribe((event) => {
if (event.kind === "stage" && event.stage === "cross") redraw();
}, "평면 횡단 판") ?? ((): void => undefined);
};
const stopStore = useDesignStore(params.projectId, null).subscribe("generation", attach);
const stop = (): void => {
stopStore();
stopPipe();
};
attach();
return {
async open(chainageM) {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, params.request().station_interval_m);
previous.show(null, interval());
watching = chainageM;
await load(chainageM, false);
attach();
drawnRoute = pipe?.input("route") ?? null;
shown = null;
redraw();
},
async refresh() {
if (watching === null) return;
await load(watching, true);
attach();
redraw();
},
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
// 반폭은 마지막으로 셈한 횡단이 덮은 폭 — 좌·우 같고, 원지반 선 범위와 한 값이다.
// 반폭 = 적용해 둔 「횡단 반폭」 · 없으면 그린 원지반 선 범위 — 좌·우 같음.
watched: () =>
watching === null || !shown
? null
@@ -102,6 +102,80 @@ 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,
cross: CrossOutput | null,
own: SectionSpecs,
intervalM: number,
): CrossSection {
const center = station.ground?.centerZ ?? null;
const head: SectionStation = {
station_id: stationIdOf(station.chainageM),
chainage_m: station.chainageM,
label: stationLabel(station.chainageM, intervalM),
kind: kindOf(station),
...(own.structure ? { structure: own.structure } : {}),
center_z: center,
azimuth_deg: station.azimuthDeg,
plan_radius_m: station.planRadiusM,
curve_outer_side: station.curveOuterSide,
curve_widening_m: station.curveWideningM,
center_x: station.x,
center_y: station.y,
uphill_side: uphillSide(station),
frame: { left_xy: station.left },
};
const sample = (offset: number, z: number | null) => ({
offset_m: offset,
elevation_m: z,
valid: z !== null && Number.isFinite(z),
});
const samples = [
...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)),
sample(0, center),
...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)),
];
const entry = cross?.byKey[station.key] as { detail?: unknown; samples?: unknown[] } | undefined;
const design = entry?.detail as CrossSection["design"] | undefined;
// 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j)
return {
...head,
...own,
// 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n)
samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples,
...(design ? { design: { ...design } } : {}),
preview: true,
};
}
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */
export function draftSectionDetail(
saved: SectionDetailResponse,
@@ -111,48 +185,14 @@ export function draftSectionDetail(
specs: ReadonlyMap<string, SectionSpecs>,
intervalM: number,
): SectionDetailResponse {
const sections: CrossSection[] = ground.stations.map((station) => {
const own = atChainage(specs.get(station.key) ?? {}, station.chainageM);
const center = station.ground?.centerZ ?? null;
const head: SectionStation = {
station_id: stationIdOf(station.chainageM),
chainage_m: station.chainageM,
label: stationLabel(station.chainageM, intervalM),
kind: kindOf(station),
...(own.structure ? { structure: own.structure } : {}),
center_z: center,
azimuth_deg: station.azimuthDeg,
plan_radius_m: station.planRadiusM,
curve_outer_side: station.curveOuterSide,
curve_widening_m: station.curveWideningM,
center_x: station.x,
center_y: station.y,
uphill_side: uphillSide(station),
frame: { left_xy: station.left },
};
const sample = (offset: number, z: number | null) => ({
offset_m: offset,
elevation_m: z,
valid: z !== null && Number.isFinite(z),
});
const samples = [
...[...(station.ground?.right ?? [])].reverse().map((s) => sample(-s.offsetM, s.z)),
sample(0, center),
...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)),
];
const entry = cross?.byKey[station.key] as
{ detail?: unknown; samples?: unknown[] } | undefined;
const design = entry?.detail as CrossSection["design"] | undefined;
// 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j)
return {
...head,
...own,
// 넓힌 표본(서버 [저장]도 넓힌 표본을 상세에 남김 — 구조물 면적이 그 지반으로 닫힘 · 349-0n)
samples: (entry?.samples as CrossSection["samples"] | undefined) ?? samples,
...(design ? { design: { ...design } } : {}),
preview: true,
};
});
const sections: CrossSection[] = ground.stations.map((station) =>
stationSection(
station,
cross,
atChainage(specs.get(station.key) ?? {}, station.chainageM),
intervalM,
),
);
const { chainageM, z } = ground.longitudinal;
return {
longitudinal: {
@@ -7,6 +7,7 @@
* - `reconcileStaleDesigns`: 낡은 design 일괄 재계산(B05 와 같은 창구)
* - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
* 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2).
* ========================================================================== */
import {
@@ -28,6 +29,7 @@ import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail";
import { useStructureIds } from "./B06_Section_Station_Keys";
import { pipelineCross } from "../A00_Common/design/design_pipeline_page";
export interface DesignSyncContext {
projectId: string | null;
@@ -125,6 +127,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
.detail()
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01)?.design;
if (design && design.ground_type !== "soil") void reconcileStaleDesigns({ force: true });
else if (projectId) pipelineCross(projectId);
}
/**
@@ -184,6 +187,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
pipelineCross(projectId); // 세션 고침 · 표준 → 묶음(같은 입력이면 안 셈)
}
/**
@@ -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(),
+130 -1
View File
@@ -177,6 +177,10 @@ async function savedBaseOf() {
}
const store = load("A00_Common", "design", "design_store.ts");
// B05 오른쪽 횡단 판(365-3) — 그림 조각만 가짜 · 값 고르기는 진짜
STUBS["b_svg_zoom_pan"] = { attachSvgZoomPan: () => undefined };
STUBS["B05_Profile_UI_RouteEdit_Cross_Draw"] = { CROSS_PLOT_PAD: {}, summarizeCross: () => "" };
const pane = load("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts");
const r3 = (value) => (typeof value === "number" ? Math.round(value * 1000) / 1000 : null);
/** 묶음 새 노선 상세(카드가 그리는 것) — 측점 · 계획고 · 절 · 성토 면적. */
@@ -199,6 +203,7 @@ async function run() {
const savedBase = await savedBaseOf();
const shop = store.useDesignStore(P, null);
const holds = [];
let unstable = 0;
const link = () => ({
pipelineSource: () => ({
base: savedBase,
@@ -215,6 +220,7 @@ async function run() {
holds.push(saved === job.detail ? "saved" : saved ? "other" : "null");
return async () => "saved";
},
onUnstable: () => (unstable += 1),
};
const open = (label) => {
const bridge = pageMod.routeDraftBridge(P, link(), options);
@@ -249,6 +255,76 @@ async function run() {
stages: Object.keys(reopenRun.stages),
};
// ⑤ 초안 동안 B06 고침 — 세션 칸(측점 열쇠) → pipelineCross → 카드(묶음 새 노선 상세)
const pipe = pageMod.pagePipeline(P);
const target = pipe.output("ground").stations.find((s) => s.kind === "grid" && s.chainageM > 30);
const cell = (ground) => ({ [target.key]: { ground_type: ground, section_mode: "left_cut" } });
const groundAt = () =>
pageMod
.pipelineDetail(P)
.cross_sections.find((s) => Math.abs(s.chainage_m - target.chainageM) < 1e-3)?.design
?.ground_type ?? null;
const before = groundAt();
page.writeState("crossdesign", cell("blasting_rock"), P, 7);
let mark = runs().length;
pageMod.pipelineCross(P);
await settle(mark + 1);
await tick(20);
const edit = {
key: target.key,
before,
after: groundAt(),
input: pipe.input("cross").byKey[target.key]?.groundType ?? null,
runs: runs().length - mark,
sources: runs()[runs().length - 1].sources,
};
// ⑦ B05 오른쪽 횡단 판 = 묶음 그 측점(카드와 같은 계획고 · 면적)
const card = pageMod.pipelineDetail(P).cross_sections;
const panes = card.map((section) => {
const shown = pane.pipelineCrossAt(pipe, section.chainage_m + 0.4, job.interval, undefined);
return [
r3(shown?.chainage_m) === r3(section.chainage_m),
r3(shown?.design?.design_elevation_m) === r3(section.design?.design_elevation_m),
r3(shown?.design?.cut_area_m2) === r3(section.design?.cut_area_m2),
shown?.samples.length === section.samples.length,
];
});
const paneCheck = {
count: card.length,
same: panes.filter((row) => row.every(Boolean)).length,
elevation: r3(
pane.pipelineCrossAt(pipe, target.chainageM, job.interval, undefined)?.design
?.design_elevation_m,
),
};
// ⑥ 고리 — 같은 입력 = 0 바퀴 · 세션만 바뀜(알림 없음) = 1 바퀴 · 안 맞음 = 3 바퀴 뒤 알림
mark = runs().length;
const same = await pageMod.reconcilePagePipeline(P);
const sameRuns = runs().length - mark;
page.writeState("crossdesign", cell("ripping_rock"), P, 7);
mark = runs().length;
const once = await pageMod.reconcilePagePipeline(P);
const loop = {
same,
sameRuns,
once,
onceRuns: runs().length - mark,
onceGround: groundAt(),
};
let n = 0;
const realStandard = STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross;
STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = () => ({
...job.standard,
n: (n += 1),
});
mark = runs().length;
loop.unstable = await pageMod.reconcilePagePipeline(P);
loop.unstableRuns = runs().length - mark;
loop.alerts = unstable;
STUBS["B06_Section_UI_Standard_Panel"].effectiveStandardCross = realStandard;
await pageMod.reconcilePagePipeline(P);
// ④ 초안 버림 — 열 때 맡긴 초안 전 상세로 되돌림(저장 정본)
again.bridge.edited(null);
const dropRun = await settle(runs().length + 1);
@@ -260,7 +336,60 @@ async function run() {
};
pageMod.disposePagePipeline(P);
page.clearDrafts(P);
return { kept, b05, b06, dropped };
// ⑨ 저장 노선 ▲▼ 한 번 = 횡단 한 벌(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()
@@ -1,6 +1,7 @@
/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 —
* `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다.
* 서버 묻기 · 그림 조각은 가짜로 채운다 — 「무슨 반폭을 묻고 · 무슨 반폭으로 선을 잡나」만 본다.
* 계산 묶음 · 그림 조각은 가짜로 채운다 — 「무슨 반폭으로 선을 잡나」만 본다(365-3 · 서버 길 없음 ·
* 묶음 그 측점 원지반 표본 = 좌우 20 m).
*/
const fs = require("fs");
const path = require("path");
@@ -56,7 +57,6 @@ globalThis.ResizeObserver = class {
disconnect() {}
};
const asked = [];
const STUBS = {
"../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} },
"../common_util/common_util_station": { stationLabel: (m) => String(m) },
@@ -65,17 +65,24 @@ const STUBS = {
CROSS_PLOT_PAD: 0,
summarizeCross: () => "",
},
// 서버 흉내 — 요청 반폭을 그대로 되돌려 주고, 없으면 기본 20.
"./B05_Profile_Api_Replan": {
fetchCrossPreview: async (_project, request) => {
asked.push(request.cross_half_width_m ?? null);
return {
chainage_m: request.chainage_m,
cross_half_width_m: request.cross_half_width_m ?? 20,
samples: [],
design: null,
};
},
// 계산 묶음 흉내 — 측점 하나(100 m) · 원지반 표본 좌우 20 m
"../A00_Common/design/design_pipeline_page": {
pagePipeline: () => ({
output: (name) =>
name === "ground" ? { stations: [{ key: "grid:5", chainageM: 100 }] } : null,
input: () => null,
subscribe: () => () => undefined,
}),
},
"../A00_Common/design/design_store": {
useDesignStore: () => ({ subscribe: () => () => undefined }),
},
"./logic/B05_Profile_Pipeline_Detail": {
stationSection: (station) => ({
chainage_m: station.chainageM,
label: null,
samples: [-20, 0, 20].map((offset_m) => ({ offset_m, elevation_m: 100, valid: true })),
}),
},
};
@@ -111,17 +118,17 @@ async function run() {
const out = {};
out.before = cross.watched();
await cross.open(100);
out.empty = { asked: asked.at(-1), watched: cross.watched() };
out.empty = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:3", "15");
await cross.refresh();
out.applied15 = { asked: asked.at(-1), watched: cross.watched() };
out.applied15 = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:12", "8.5");
session.setItem("aislo:draft:cross-display:p-2:99", "40");
await cross.refresh();
out.latestRoute = { asked: asked.at(-1), watched: cross.watched() };
out.latestRoute = { watched: cross.watched() };
session.setItem("aislo:draft:cross-display:p-1:12", "abc");
await cross.refresh();
out.badValue = { asked: asked.at(-1), watched: cross.watched() };
out.badValue = { watched: cross.watched() };
return out;
}
run().then((out) => console.log(JSON.stringify(out)));
@@ -138,10 +138,6 @@ def test_수치지형도_서피스_없으면_라이다만(tmp_path):
def test_허용_목록_밖_거절(bad):
with pytest.raises(ValidationError):
terrain_router.ElevationRequest(points=[(0.0, 0.0)], terrain=bad)
with pytest.raises(ValidationError):
terrain_router.CrossPreviewRequest(
vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad
)
with pytest.raises(ValidationError):
replan.RouteInitialRequest(terrain=bad)
@@ -167,8 +163,8 @@ def test_화면_고르기_배선():
assert '<option value="sheet">수치지형도</option>' in chrome
assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main
assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api
# 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 2
# 모달 지반고가 고른 지형을 실어 보냄 · 횡단 미리보기 = 묶음(365-3) · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 1
assert "terrain: analysisTerrain(projectId)" in initial_api
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
assert "terrain" not in replan_fn
@@ -359,30 +359,3 @@ def test_측점_셈_둘_다_스냅을_읽음(tmp_path: Path, monkeypatch) -> Non
with pytest.raises(RuntimeError):
sections.run_section_generation(tmp_path, "route.geojson", "f", "m", False)
assert seen["station_snap"] is True
def test_횡단_미리보기도_스냅(tmp_path: Path, monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Terrain as terrain
from common_util.common_util_route_polyline import build_planned_polyline
monkeypatch.setattr(terrain, "build_surface_sampler", lambda *_args, **_kw: _Flat())
built = build_planned_polyline(_NODES, min_radius_m=40.0, simplify=False)
points, chain = core._clean_polyline(np.asarray(built.vertices, dtype=np.float64))
spans = core._design_curve_spans(points, chain, [c.as_dict() for c in built.curves])
assert len(spans) == 1
target = math.floor((spans[0][0] + spans[0][1]) / 2 + 0.5)
assert target % 20 # 격자 밖 자리여야 견줄 뜻이 있음
def ask(snap: bool) -> float:
request = terrain.CrossPreviewRequest(
vertices=[{"x": x, "y": y} for x, y in _NODES],
chainage_m=target,
min_radius_m=40.0,
station_interval_m=20.0,
station_snap=snap,
)
params = {"source_filter": "f", "method": "m", "smooth": False}
return terrain._cross_preview(tmp_path, params, request)["chainage_m"]
assert ask(True) == target
assert ask(False) % 20 == 0
@@ -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):
@@ -5,6 +5,15 @@
- 노선 초안 둔 채 다시 열기(새로고침 · B06 바로 열기 · 편집기 없음) = B05 길과 같은 초안 상세
(측점 · 계획고 · 면적) · 측점을 가게에 다시 냄 · 초안 전 = 저장 상세(열 때 맡김) → 버리면 saved.
- 초안 동안 측점 갈아끼움은 메모리만 — 세션 상세(저장 노선)를 새 측점 + 옛 설계로 덮지 않음.
못박는 것(365-2)
- 초안 동안 B06 고침(세션 칸 · 측점 열쇠) → `pipelineCross` → 묶음 cross 입력 · 카드에 보임.
- 고리 — 같은 입력 = 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).
@@ -99,6 +108,58 @@ def test_365_1_초안_버림_저장_정본(r):
assert d["restoreWhy"] == "saved"
def test_365_2_초안_동안_횡단_고침_카드에(r):
e = r["edit"]
assert e["before"] != "blasting_rock"
assert e["input"] == "blasting_rock" and e["after"] == "blasting_rock"
assert e["runs"] == 1 and e["sources"] == ["cross"]
def test_365_2_고리_바퀴_수(r):
loop = r["loop"]
assert loop["same"] == 0 and loop["sameRuns"] == 0 # 같은 입력 = 안 돎
assert loop["once"] == 1 and loop["onceRuns"] == 1 and loop["onceGround"] == "ripping_rock"
assert loop["unstable"] == -1 and loop["unstableRuns"] == 3 and loop["alerts"] == 1
def test_365_3_평면_횡단_판_카드와_같음(r):
p = r["pane"]
assert p["count"] > 5 and p["same"] == p["count"]
assert p["elevation"] is not None
def test_365_3_서버_횡단_미리보기_길_없음():
router = (ROOT / "B05_Profile/B05_Profile_Router_Terrain.py").read_text(encoding="utf-8")
api = (ROOT / "B05_Profile/B05_Profile_Api_Replan.ts").read_text(encoding="utf-8")
edit = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "cross-preview" not in router and "cross-preview" not in api
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")
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증.
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 서버 기본값.
선 길이는 서버가 덮은 반폭(= 횡단 미리보기 원지반 선 범위)을 그대로 쓴다.
파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 원지반 선 범위
(계산 묶음 그 측점 표본 · 365-3).
Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다.
"""
@@ -31,21 +31,17 @@ def test_측점을_고르기_전에는_선이_없다(results):
assert results["before"] is None
def test_적용한_적_없으면_서버_기본값_양쪽(results):
assert results["empty"]["asked"] is None
def test_적용한_적_없으면_원지반_범위_양쪽(results):
assert results["empty"]["watched"] == {"chainageM": 100, "leftM": 20, "rightM": 20}
def test_적용값이_있으면_그_값_양쪽(results):
assert results["applied15"]["asked"] == 15
assert results["applied15"]["watched"] == {"chainageM": 100, "leftM": 15, "rightM": 15}
def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(results):
assert results["latestRoute"]["asked"] == 8.5
assert results["latestRoute"]["watched"]["leftM"] == 8.5
def test_망가진_값은_기본값으로(results):
assert results["badValue"]["asked"] is None
assert results["badValue"]["watched"]["rightM"] == 20
+4
View File
@@ -1067,4 +1067,8 @@ export const ui_locales_b2 = {
"지금 관은 그대로 두고 간격이 먼 구간에만 관을 더함",
"Keep the current culverts and add culverts only where the spacing is too long",
],
Design_Pipeline_Unstable: [
"계산 불안정 — 입력과 결과가 세 바퀴 안에 안 맞음",
"Calculation unstable — inputs and results did not settle in three rounds",
],
} as const satisfies Record<string, LocaleEntry>;