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:
@@ -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[] } };
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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) · 상세가 없으면 묶음 측점으로 갈아끼움
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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:
|
||||
"""편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김."""
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user