Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -8,9 +8,17 @@
|
||||
* 입력 = 관 알림(배수유역도 · B06 관 목록) · 구조물(보기 고침 · 저장분) · 종류 표 · 측점 간격 · 추가 측점(가게)
|
||||
* 관 id 장부 = 관 고정 id 가 쥔 세션 id 를 그 자리로(411-B) · 헛 id 정리(411-E) — 어느 보기에서 고쳐도
|
||||
* 계획고 편집 옮김(사라진 측점 → 새 측점 · 213-2) = 사용자 고침만 · 노선 따라감(`follow`)은 안 옮김(계산 묶음 몫 · 349-0d)
|
||||
* 소단 사본(세션 `berm`) = 구조물 소단 구간 — 쓰는 자리 여기 하나(B05 만 열어도 · 같으면 안 씀 · 쓰면 cross 한 번 · 467)
|
||||
* ========================================================================== */
|
||||
|
||||
import { useDesignStore } from "./design_store";
|
||||
import { pageRouteId } from "./design_page_route";
|
||||
import { readState } from "../b_page_state";
|
||||
import {
|
||||
bermSpansFromStructures,
|
||||
writeBermSpans,
|
||||
type BermSpan,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
import { pipeIdAt, placePipeIds, prunePipeIds, withPipeIds } from "./design_pipe_ids";
|
||||
import { plannedIrregular } from "./design_structure_status";
|
||||
import { planStations } from "../../common_util/common_util_structure_station";
|
||||
@@ -85,8 +93,9 @@ export function createFacilityLane(
|
||||
): FacilityLane {
|
||||
const store = useDesignStore(projectId, null);
|
||||
let types: StructureType[] = [];
|
||||
let stored: StructureInstance[] = [];
|
||||
let stored: StructureInstance[] | null = null;
|
||||
let structures: StructureInstance[] = [];
|
||||
let structuresKnown = false; // 구조물을 받기 전 = 소단 사본 안 씀(빈 목록으로 저장분 소단을 지우지 않게)
|
||||
/** 마지막 관 알림 — 노선 따라감 · 관 초안이 바뀌면 이것을 초안 측점으로 다시 맞춤. */
|
||||
let reported: LanePipe[] | null = null;
|
||||
let pipes: LanePipe[] = [];
|
||||
@@ -139,6 +148,23 @@ export function createFacilityLane(
|
||||
|
||||
const marks = (): StructureInstance[] => [...structures, ...pipesToStructureMarks(pipes)];
|
||||
|
||||
/** 소단 사본 = 지금 구조물의 소단 구간(467 · 사본 없는 동안 다시 셈 = 저장분 `design.berm`) · 같으면 안 씀 ·
|
||||
* 쓰면 저장 노선 cross 한 번 더(같은 값 측점은 다시 셈 기억이 건너뜀 · 묶음은 늦게 불러옴 · 순환 없음). */
|
||||
function syncBerm(): void {
|
||||
const routeId = pageRouteId(projectId);
|
||||
if (!structuresKnown || routeId === null) return;
|
||||
const copy = readState<BermSpan[]>("berm", projectId, routeId);
|
||||
const next = bermSpansFromStructures(structures);
|
||||
if (Array.isArray(copy) && JSON.stringify(copy) === JSON.stringify(next)) return;
|
||||
writeBermSpans(projectId, routeId, next);
|
||||
void Promise.all([import("./design_pipeline_cross"), import("./design_pipeline_page")])
|
||||
.then(([cross, page]) => {
|
||||
cross.forceCross();
|
||||
page.pipelineCross(projectId);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function paint(force = false): void {
|
||||
const to = profile();
|
||||
if (!to) return;
|
||||
@@ -179,13 +205,20 @@ export function createFacilityLane(
|
||||
stored = next;
|
||||
},
|
||||
set(next, carryEdits = true) {
|
||||
if (next.structures) structures = next.structures;
|
||||
if (next.structures) {
|
||||
structures = next.structures;
|
||||
structuresKnown = true;
|
||||
}
|
||||
if (next.pipes) reported = [...next.pipes];
|
||||
refresh(carryEdits);
|
||||
if (next.structures) run(syncBerm);
|
||||
},
|
||||
follow() {
|
||||
structures = store.get("structures") ?? stored;
|
||||
const drafted = store.get("structures");
|
||||
structures = drafted ?? stored ?? [];
|
||||
structuresKnown ||= drafted !== null || stored !== null;
|
||||
refresh(false);
|
||||
run(syncBerm);
|
||||
},
|
||||
setIntervalM(next) {
|
||||
if (next > 0 && next !== intervalM) {
|
||||
|
||||
@@ -45,11 +45,19 @@ const isEditKey = (key: string, projectId: string): boolean =>
|
||||
/** 한 시점의 조작값 — 키 → 값(JSON 문자열). */
|
||||
export type HistorySnapshot = Record<string, string>;
|
||||
|
||||
/** 빈 칸 = 칸 없음(272 · 떠남 확인 `filled` 와 한 규칙) — 빈 맵을 쓴 것은 걸음이 아님.
|
||||
* 「[]」 는 목록 초안(관 · 구조물 모두 지움 ≠ 저장분)이라 값으로 찍음. */
|
||||
const EMPTY_CELLS = new Set(["", "{}", "null"]);
|
||||
const cellOf = (key: string): string | null => {
|
||||
const value = readByKey(key);
|
||||
return value === null || EMPTY_CELLS.has(value) ? null : value;
|
||||
};
|
||||
|
||||
/** `previous` 와 같은 값은 그 글을 그대로 담음(메모리 한 벌). 저장소가 막히면 빈 사진 — 되돌리기만 꺼짐. */
|
||||
function takeSnapshot(projectId: string, previous?: HistorySnapshot): HistorySnapshot {
|
||||
const snapshot: HistorySnapshot = {};
|
||||
for (const key of editKeys(projectId)) {
|
||||
const value = readByKey(key);
|
||||
const value = cellOf(key);
|
||||
if (value !== null) snapshot[key] = previous?.[key] === value ? previous[key] : value;
|
||||
}
|
||||
return snapshot;
|
||||
@@ -255,7 +263,7 @@ export function createProfileHistory(
|
||||
stack[cursor] = takeSnapshot(projectId, stack[cursor]); // 쉬는 동안 온 값 = 지금 걸음의 모습
|
||||
};
|
||||
seedActive = (key) => {
|
||||
const value = isEditKey(key, projectId) ? readByKey(key) : null;
|
||||
const value = isEditKey(key, projectId) ? cellOf(key) : null;
|
||||
if (value !== null) stack.forEach((snapshot) => void (snapshot[key] ??= value));
|
||||
};
|
||||
active = history;
|
||||
|
||||
@@ -139,6 +139,8 @@ export interface RouteMassHaulDrawParams {
|
||||
pendingRecalc?: boolean;
|
||||
/** 다시 셈 뒤에도 남은 낡은 측점 — 곡선은 그리고 이 측점만 표시(342-1 · 사용자 확정 10-16). */
|
||||
staleChainages?: number[];
|
||||
/** 종단 본문이 0 크기(판 접힘) — 이 판도 자리가 없으면 총괄값만(떼어 낸 창이면 그림 · 272). */
|
||||
bodyHidden?: boolean;
|
||||
}
|
||||
|
||||
export interface RouteMassHaulPanel {
|
||||
@@ -339,7 +341,7 @@ export function createRouteMassHaulPanel(
|
||||
syncHandlePosition();
|
||||
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
|
||||
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
|
||||
if (!open) {
|
||||
if (!open || (params.bodyHidden && overlay.clientWidth <= 0)) {
|
||||
reportSummary(params);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -128,11 +128,101 @@ export interface ProfileRenderContext {
|
||||
setElevationWindowUpdater: (update: (() => ElevationWindowResult | null) | null) => void;
|
||||
}
|
||||
|
||||
/** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */
|
||||
/** 그림과 상관없는 파생값 — 낡음 예약 · 측점 자료(272 · 접힘 · 0 크기에도 같은 값). */
|
||||
function deriveProfile(
|
||||
ctx: ProfileRenderContext,
|
||||
detail: SectionDetailResponse,
|
||||
alignment: ProfileAlignment | null,
|
||||
irregularStations: IrregularStation[],
|
||||
) {
|
||||
const longitudinal = detail.longitudinal;
|
||||
const designProfiles = alignment
|
||||
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
// 횡단이 지금 계획선 · 얹힌 구조물과 어긋나면(낡음) 패널이 다시 셈을 예약 — 같은 낡음은 한 번만(317-1)
|
||||
const stale = staleKey({
|
||||
longitudinal: { design_profiles: designProfiles },
|
||||
cross_sections: detail.cross_sections,
|
||||
});
|
||||
const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";
|
||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||
const graphData = normalizedLongitudinal(longitudinal);
|
||||
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
|
||||
// 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`).
|
||||
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
||||
const graphLongitudinal = {
|
||||
...graphData,
|
||||
stations: oneStationList(graphData.stations, injected),
|
||||
};
|
||||
return { designProfiles, stale, pending, graphData, graphLongitudinal };
|
||||
}
|
||||
type DerivedProfile = ReturnType<typeof deriveProfile>;
|
||||
|
||||
/** 유토곡선에 넘길 값 — 그림과 숨은 셈이 한 벌(가로 배치만 다름). */
|
||||
function massHaulParamsOf(
|
||||
ctx: ProfileRenderContext,
|
||||
detail: SectionDetailResponse,
|
||||
derived: DerivedProfile,
|
||||
view: { originOffset: number; fromM: number; toM: number; widthPx: number },
|
||||
stationIntervalM: number | undefined,
|
||||
bodyHidden: boolean,
|
||||
): RouteMassHaulDrawParams {
|
||||
const { longitudinal } = detail;
|
||||
const { designProfiles, stale, pending } = derived;
|
||||
return {
|
||||
stationSource: derived.graphLongitudinal,
|
||||
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
|
||||
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
|
||||
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
|
||||
crossSections: detail.cross_sections,
|
||||
// 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이
|
||||
// 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
|
||||
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
|
||||
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
|
||||
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
|
||||
pendingRecalc: pending,
|
||||
staleChainages: pending ? [] : staleChainages(stale),
|
||||
axis: {
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
|
||||
padLeft: LONG_PAD.left + view.originOffset,
|
||||
padRight: LONG_PAD.right + view.originOffset,
|
||||
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
|
||||
axisX: LONG_PAD.left,
|
||||
// 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다
|
||||
// (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음).
|
||||
padTop: 30,
|
||||
// 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도
|
||||
// 곡선이 납작하게 눌린다(2026-09-04 사용자 지시).
|
||||
viewRange: { fromM: view.fromM, toM: view.toM },
|
||||
},
|
||||
stationInterval: stationIntervalM ?? 1,
|
||||
widthPx: view.widthPx,
|
||||
selectedStationId: ctx.selectedStationId(),
|
||||
onSelectStation: ctx.selectStation,
|
||||
bodyHidden,
|
||||
};
|
||||
}
|
||||
|
||||
/** 본문을 통째로 다시 그린다. 상세가 없으면 아무것도 하지 않는다.
|
||||
* 본문이 0 크기(판 접힘 · 아직 자리 없음)면 파생값(낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료)만
|
||||
* 셈 · 그리기만 건너뜀 — 펼침 · 떼어 낸 유토 창과 같은 값(272). */
|
||||
export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
const { body, callbacks, massHaul, tableOverlay, store } = ctx;
|
||||
const detail = ctx.detail();
|
||||
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
|
||||
if (!detail) return;
|
||||
if (body.clientWidth <= 0 || body.clientHeight <= 0) {
|
||||
const alignment = ctx.alignment();
|
||||
const derived = deriveProfile(ctx, detail, alignment, ctx.irregularStations());
|
||||
if (alignment && derived.designProfiles[0]) {
|
||||
const maxM = maxChainageOf(detail.longitudinal);
|
||||
const view = { originOffset: 0, fromM: 0, toM: maxM, widthPx: 1 };
|
||||
const interval = ctx.stationInterval() ?? alignment.policy.station_interval_m;
|
||||
massHaul.draw(massHaulParamsOf(ctx, detail, derived, view, interval, true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const alignment = ctx.alignment();
|
||||
const base = ctx.base();
|
||||
const stationInterval = ctx.stationInterval();
|
||||
@@ -252,24 +342,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
intervalM: stationIntervalM ?? 20,
|
||||
});
|
||||
});
|
||||
const designProfiles = alignment
|
||||
? [toDesignProfile(alignment, longitudinal.design_profiles?.[0])]
|
||||
: (longitudinal.design_profiles ?? []);
|
||||
// 횡단이 지금 계획선 · 얹힌 구조물과 어긋나면(낡음) 패널이 다시 셈을 예약 — 같은 낡음은 한 번만(317-1)
|
||||
const stale = staleKey({
|
||||
longitudinal: { design_profiles: designProfiles },
|
||||
cross_sections: detail.cross_sections,
|
||||
});
|
||||
const pending = ctx.onStaleDesigns?.(stale) ?? stale !== "";
|
||||
// 그래프에는 비정규 측점을 일반 측점처럼(세로선+라벨) 섞어 넣는다.
|
||||
const graphData = normalizedLongitudinal(longitudinal);
|
||||
// 측점 한 벌(272) — 서버의 구조물 측점은 사이드바 목록(관을 옮기면 따라옴)이 갈음하고, 같은 m
|
||||
// 일반 측점은 숨김(구조물이 위) · 표 · ▲▼ · 3D 와 같은 목록(`oneStationList`).
|
||||
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
|
||||
const graphLongitudinal = {
|
||||
...graphData,
|
||||
stations: oneStationList(graphData.stations, injected),
|
||||
};
|
||||
const derived = deriveProfile(ctx, detail, alignment, irregularStations);
|
||||
const { designProfiles, graphData, graphLongitudinal } = derived;
|
||||
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
|
||||
// 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다.
|
||||
const toChainage = chainageInverter(longitudinal, width, originOffset);
|
||||
@@ -441,39 +515,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정).
|
||||
// X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
|
||||
if (alignment && designProfiles[0]) {
|
||||
const massHaulParams: RouteMassHaulDrawParams = {
|
||||
stationSource: graphLongitudinal,
|
||||
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
|
||||
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
|
||||
longitudinal: { length_m: longitudinal.length_m, design_profiles: designProfiles },
|
||||
crossSections: detail.cross_sections,
|
||||
// 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 — Panel이
|
||||
// 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
|
||||
// 비교 대상은 **편집이 반영된 계획선**(`designProfiles`)이다. 저장분
|
||||
// (`detail.longitudinal.design_profiles`)과 견주면 편집 중에는 영원히 어긋난 것으로
|
||||
// 나와 곡선이 계속 빈 화면이 된다(2026-09-03 사용자 보고).
|
||||
pendingRecalc: pending,
|
||||
staleChainages: pending ? [] : staleChainages(stale),
|
||||
axis: {
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
// 좌우 여백에 합쳐 넘긴다 — 어긋나면 같은 측점이 두 그래프에서 다른 자리에 선다.
|
||||
padLeft: LONG_PAD.left + originOffset,
|
||||
padRight: LONG_PAD.right + originOffset,
|
||||
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
|
||||
axisX: LONG_PAD.left,
|
||||
// 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다
|
||||
// (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음).
|
||||
padTop: 30,
|
||||
// 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도
|
||||
// 곡선이 납작하게 눌린다(2026-09-04 사용자 지시).
|
||||
viewRange: { fromM: viewFromM, toM: viewToM },
|
||||
},
|
||||
stationInterval: stationIntervalM ?? 1,
|
||||
widthPx: width,
|
||||
selectedStationId,
|
||||
onSelectStation: ctx.selectStation,
|
||||
};
|
||||
massHaul.draw(massHaulParams);
|
||||
const view = { originOffset, fromM: viewFromM, toM: viewToM, widthPx: width };
|
||||
massHaul.draw(massHaulParamsOf(ctx, detail, derived, view, stationIntervalM, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +34,15 @@ import {
|
||||
readCutSlopeSession,
|
||||
readRockBoundarySession,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSpec } from "@util/common_util_cross_berm";
|
||||
import type { BermSessionSpec, BermSpan } from "./B06_Section_UI_Page_Persist";
|
||||
import {
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
type BermSpec,
|
||||
} from "@util/common_util_cross_berm";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
crossDesignChoices,
|
||||
readBermDepths,
|
||||
@@ -260,6 +267,24 @@ function bermPayload(
|
||||
type Side = "left" | "right";
|
||||
const SIDES: Side[] = ["left", "right"];
|
||||
|
||||
/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */
|
||||
export function storedBermSpec(berm: unknown): BermSessionSpec | null {
|
||||
if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null;
|
||||
const spec = berm as Record<string, unknown>;
|
||||
const value = (key: string, fallback: number): number => {
|
||||
const raw = spec[key];
|
||||
if (raw === undefined) return fallback;
|
||||
return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw);
|
||||
};
|
||||
const out = {
|
||||
width_m: value("width_m", BERM_DEFAULT_WIDTH_M),
|
||||
interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M),
|
||||
slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
||||
depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0),
|
||||
};
|
||||
return Object.values(out).every(Number.isFinite) ? out : null;
|
||||
}
|
||||
|
||||
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
|
||||
function bermDepthReader(projectId: string, routeId: number) {
|
||||
const session = readBermDepths(projectId, routeId);
|
||||
@@ -333,11 +358,13 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
|
||||
const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId);
|
||||
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
||||
// 순간 계단이 사라진다(계획서 3-9).
|
||||
const bermSpans = readBermSpans(projectId, input.routeId);
|
||||
// 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`
|
||||
// (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467).
|
||||
const bermCopy = readState<BermSpan[]>("berm", projectId, input.routeId);
|
||||
const bermSpans = Array.isArray(bermCopy) ? bermCopy : null;
|
||||
const bermDepthAt = bermDepthReader(projectId, input.routeId);
|
||||
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
|
||||
const spec = bermSpecAt(bermSpans, chainageM);
|
||||
const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm);
|
||||
return spec
|
||||
? {
|
||||
widthM: spec.width_m,
|
||||
|
||||
@@ -41,6 +41,7 @@ import { staleKeyAgainstScreen, USER_TOUCHED_KEYS } from "./B06_Section_Cross_Re
|
||||
import { kindChangedChainages, STRUCTURE_ADJUST_KEYS } from "./B06_Section_Api_Culvert_Options";
|
||||
import type { PipelineStation } from "../A00_Common/design/logic/design_pipeline_types";
|
||||
import type { DesignStoreValues } from "../A00_Common/design/design_store";
|
||||
import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
import {
|
||||
carryStationKeys,
|
||||
KEYED_STATES,
|
||||
@@ -237,7 +238,13 @@ export async function swapSectionStations(
|
||||
const interval = stationIntervalOf(current);
|
||||
const oldKeys = registeredKeys(projectId, routeId, current.cross_sections, interval);
|
||||
if (sameStationList(current.cross_sections, oldKeys, stations)) return null;
|
||||
const swap = swapCrossSections(current, stations, oldKeys, interval);
|
||||
const swap = swapCrossSections(
|
||||
current,
|
||||
stations,
|
||||
oldKeys,
|
||||
interval,
|
||||
useDesignStore(projectId, null).get("routeDraft") !== null,
|
||||
); // 초안 없으면 정본 표본(467)
|
||||
// 설계는 사본 — 면적 얹기 · 다시 셈이 앞 상세(초안 전 상세 등)의 설계를 제자리로 안 고침(349-0o)
|
||||
for (const section of swap.detail.cross_sections)
|
||||
if (section.design) section.design = { ...section.design };
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - `handleDesignChange` : 버튼 선택 즉시 반영 + 세션 초안 기록 + 재계산
|
||||
* - `recomputeIfRock` : 암 경계·암 절토 경사 변경 후 암 측점만 재계산
|
||||
* - `reconcileStaleDesigns`: 횡단 다시 셈을 계산 묶음에 부탁(cross 단계 한 곳 · 387-4)
|
||||
* - `syncBermSpans` : 구조물 목록의 소단을 세션 사본으로 펴기
|
||||
* - `syncBermSpans` : 구조물 측점 열쇠(소단 사본은 페이지 시설 줄이 씀 · 467)
|
||||
* - `applyPanelToAll` : 표준 횡단면 설정 [전체 반영]
|
||||
* 고친 뒤 계산 묶음 cross 입력도 다시(`pipelineCross` · 노선 초안 동안 카드 = 묶음 새 노선 상세 · 365-2).
|
||||
* ========================================================================== */
|
||||
@@ -15,11 +15,6 @@ import {
|
||||
writeCrossDesignChoice,
|
||||
writeShoulderExtend,
|
||||
} from "./B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
bermSpansFromStructures,
|
||||
readBermSpans,
|
||||
writeBermSpans,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { L } from "./B06_Section_UI_Page_Common";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
@@ -137,20 +132,10 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
await reconcilePagePipeline(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조물 목록의 **소단**(C군 사면안정)을 세션 사본으로 편다 — 재계산이 측점마다 읽는 값이다.
|
||||
*
|
||||
* 사용자는 「구조물 배치」에서 놓고(2026-09-07 확정), 계산은 그 사본만 본다. 달라졌을 때만
|
||||
* 다시 계산한다 — 목록은 화면을 열 때도 오므로 매번 돌리면 진입이 느려진다.
|
||||
*/
|
||||
/** 구조물 측점 열쇠 = 구조물 id(344-3) — 소단 사본(세션 `berm`)은 페이지 시설 줄 한 곳이 씀(B05 만 열어도 · 467). */
|
||||
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
|
||||
const currentRouteId = ctx.routeId();
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
useStructureIds(projectId, structures); // 구조물 측점 열쇠 = 구조물 id(344-3)
|
||||
const next = bermSpansFromStructures(structures);
|
||||
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
|
||||
writeBermSpans(projectId, currentRouteId, next);
|
||||
void reconcileStaleDesigns({ force: true });
|
||||
if (!projectId || ctx.routeId() === null) return;
|
||||
useStructureIds(projectId, structures);
|
||||
}
|
||||
|
||||
/** 패널 [전체 반영](N-2-1 · 346-2): 표준값으로 전 측점을 **한 번** 다시 셈 — 측점마다 돌면
|
||||
|
||||
@@ -35,7 +35,7 @@ export interface SessionMap<V> {
|
||||
values: Map<string, V>;
|
||||
/** 세션값 읽기 — 맵을 비우고 다시 채운다. */
|
||||
load: () => void;
|
||||
/** 맵을 통째로 세션에 쓴다. */
|
||||
/** 맵을 통째로 세션에 쓴다 — 빈 맵이면 칸을 지운다. */
|
||||
persist: () => void;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,8 @@ export function createSessionMap<V>(
|
||||
const key = sessionKey();
|
||||
if (!key) return;
|
||||
try {
|
||||
writeByKey(key, JSON.stringify(Object.fromEntries(values)));
|
||||
// 빈 맵 = 칸 지움(272) — 「{}」 를 남기면 사진첩이 걸음으로 봄
|
||||
writeByKey(key, values.size ? JSON.stringify(Object.fromEntries(values)) : null);
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
|
||||
}
|
||||
@@ -144,10 +145,12 @@ export function createLinkFlagSession(sessionKey: () => string | null): LinkFlag
|
||||
if (!key) return;
|
||||
writeByKey(
|
||||
key,
|
||||
JSON.stringify({
|
||||
detached: Object.fromEntries(detached),
|
||||
grade: Object.fromEntries(followGrade),
|
||||
}),
|
||||
detached.size || followGrade.size
|
||||
? JSON.stringify({
|
||||
detached: Object.fromEntries(detached),
|
||||
grade: Object.fromEntries(followGrade),
|
||||
})
|
||||
: null, // 둘 다 비면 칸 지움(272)
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* 받음 — 설계 · 구조물 제원 · 이름 그대로 · 자리(누가거리 · 좌표 · 방위 · 확폭)만 새것(433-4).
|
||||
* · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지
|
||||
* 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면).
|
||||
* · 노선 초안 없는 차례(관 · 구조물 · 추가 측점만) = 같은 자리(누가거리 · 좌표 0.01 m · 축 1e-3) 앞 횡단 — 없으면
|
||||
* 그 자리 옛 횡단(격자에 구조물을 놓고 뺌) — 의 기하 · 표본 그대로(브라우저 측점 틀 ↔ 서버 틀 mm 차가 정본 표본을
|
||||
* 안 덮음 · 467 · 요구 272) · 설계는 지금 규칙.
|
||||
* · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움).
|
||||
* · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428).
|
||||
* · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가
|
||||
@@ -146,27 +149,31 @@ export interface StationSwap {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */
|
||||
function samePlace(prior: CrossSection, station: PipelineStation): boolean {
|
||||
/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터 · 허용 `axisTol`)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */
|
||||
function samePlace(prior: CrossSection, station: PipelineStation, axisTol = 1e-4): boolean {
|
||||
const [lx, ly] = prior.frame?.left_xy ?? [Number.NaN, Number.NaN];
|
||||
return (
|
||||
Math.abs(prior.chainage_m - station.chainageM) < 0.01 &&
|
||||
Math.hypot(prior.center_x - station.x, prior.center_y - station.y) <= 0.01 &&
|
||||
Math.abs(lx - station.left[0]) < 1e-4 &&
|
||||
Math.abs(ly - station.left[1]) < 1e-4
|
||||
Math.abs(lx - station.left[0]) < axisTol &&
|
||||
Math.abs(ly - station.left[1]) < axisTol
|
||||
);
|
||||
}
|
||||
|
||||
/** 노선 초안 없는 차례의 축 허용(측점 축 0.06° · 20 m 끝 2 cm) — 브라우저 틀 ↔ 서버 틀 차(R 12 곡선 3.3e-4)를 넘김. */
|
||||
const CANON_AXIS_TOL = 1e-3;
|
||||
|
||||
/**
|
||||
* 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) ·
|
||||
* 열쇠가 안 맞으면 옮긴 곡선 열쇠(`curveMoves` · 서버 `carry_cross_inputs` 같은 규칙) · 그다음 같은 자리(0.01 m) ·
|
||||
* 같은 종류의 앞 횡단을 씀.
|
||||
* 같은 종류의 앞 횡단을 씀. `routeDraft` = 노선 초안 차례(가게 `routeDraft` · 아니면 정본 기하 · 표본 유지 · 467).
|
||||
*/
|
||||
export function swapCrossSections(
|
||||
detail: SectionDetailResponse,
|
||||
stations: ReadonlyArray<PipelineStation>,
|
||||
oldKeys: ReadonlyArray<string>,
|
||||
intervalM: number | null,
|
||||
routeDraft = false,
|
||||
): StationSwap {
|
||||
const byKey = new Map(detail.cross_sections.map((section, i) => [oldKeys[i], section]));
|
||||
const used = new Set<CrossSection>();
|
||||
@@ -209,8 +216,18 @@ export function swapCrossSections(
|
||||
moved = true;
|
||||
if (prior && text(prior.chainage_m) !== text(station.chainageM))
|
||||
moves.set(text(prior.chainage_m), text(station.chainageM));
|
||||
// 노선 초안 없는 차례 · 같은 자리 = 앞 횡단(없으면 그 자리 옛 횡단 · 격자에 구조물을 놓고 뺌) 기하 · 표본 그대로
|
||||
// (467 · 되돌리면 정본과 같음)
|
||||
const atPlace = (section: CrossSection | undefined): section is CrossSection =>
|
||||
!!section?.samples?.length && samePlace(section, station, CANON_AXIS_TOL);
|
||||
const canon = routeDraft
|
||||
? undefined
|
||||
: atPlace(prior)
|
||||
? prior
|
||||
: detail.cross_sections.find(atPlace);
|
||||
// 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면)
|
||||
const fresh = !!station.ground && !(prior?.samples?.length && samePlace(prior, station));
|
||||
const fresh =
|
||||
!canon && !!station.ground && !(prior?.samples?.length && samePlace(prior, station));
|
||||
const kind = kindOf(station);
|
||||
const onGrid =
|
||||
kind === "irregular" &&
|
||||
@@ -230,20 +247,38 @@ export function swapCrossSections(
|
||||
label: formatStation(station.chainageM),
|
||||
kind,
|
||||
...(onGrid ? { grid: true } : {}),
|
||||
center_z: fresh
|
||||
? (station.ground?.centerZ ?? prior?.center_z ?? null)
|
||||
: (prior?.center_z ?? null),
|
||||
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,
|
||||
frame: { left_xy: [station.left[0], station.left[1]] as [number, number] },
|
||||
center_z: canon
|
||||
? canon.center_z
|
||||
: fresh
|
||||
? (station.ground?.centerZ ?? prior?.center_z ?? null)
|
||||
: (prior?.center_z ?? null),
|
||||
...(canon
|
||||
? {
|
||||
azimuth_deg: canon.azimuth_deg,
|
||||
plan_radius_m: canon.plan_radius_m,
|
||||
curve_outer_side: canon.curve_outer_side,
|
||||
curve_widening_m: canon.curve_widening_m,
|
||||
center_x: canon.center_x,
|
||||
center_y: canon.center_y,
|
||||
frame: canon.frame,
|
||||
}
|
||||
: {
|
||||
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,
|
||||
frame: { left_xy: [station.left[0], station.left[1]] as [number, number] },
|
||||
}),
|
||||
};
|
||||
longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) });
|
||||
const samples = fresh ? groundSamples(station) : (prior?.samples ?? []);
|
||||
const uphill = fresh ? uphillSide(station) : (prior?.uphill_side ?? null);
|
||||
const samples = canon ? canon.samples : fresh ? groundSamples(station) : (prior?.samples ?? []);
|
||||
const uphill = canon
|
||||
? (canon.uphill_side ?? null)
|
||||
: fresh
|
||||
? uphillSide(station)
|
||||
: (prior?.uphill_side ?? null);
|
||||
if (!prior) return { ...place, uphill_side: uphill, samples, design: freshDesign(uphill) };
|
||||
const reseed =
|
||||
(station.kind === "pipe" || station.kind === "structure") &&
|
||||
|
||||
@@ -21,9 +21,9 @@ export interface MassHaulBadgeValues {
|
||||
shortageM3: number;
|
||||
}
|
||||
|
||||
/** 누가토량 표기 — 천 단위 구분 + 소수점 1자리(유토곡선 요약과 같은 규칙). */
|
||||
/** 누가토량 표기 — 천 단위 구분 + 소수점 1자리 고정(0 도 「.0」 · 272). */
|
||||
function volume(value: number): string {
|
||||
return `${value.toLocaleString("ko-KR", { maximumFractionDigits: 1 })}㎥`;
|
||||
return `${value.toLocaleString("ko-KR", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}㎥`;
|
||||
}
|
||||
|
||||
export interface MassHaulBadge {
|
||||
|
||||
@@ -35,6 +35,8 @@ const STUBS = {
|
||||
}),
|
||||
"ui_template_elements.ts": anyModule(),
|
||||
"B04_PreProcess_Api_Fetch.ts": anyModule(),
|
||||
// 소단 사본(467) — 끌어오는 줄이 import.meta 를 써 이 로더로 못 읽음 · 소단 몫은 test_272_berm_source(vite 묶음)
|
||||
"B06_Section_UI_Page_Persist.ts": anyModule({ bermSpansFromStructures: () => [] }),
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/* 467 헬퍼 — 소단 원천 한 벌: 구조물 → 소단 구간(TS) · 저장분 소단 제원(TS) · 시설 줄이 세션 사본을 씀 ·
|
||||
* 다시 셈 = 사본 없으면 저장분 `design.berm`.
|
||||
* node helper_272_berm_source.mjs <묶음.mjs> <job.json>
|
||||
* job = { standard, rockDefault, structureCases, storedCases, ground } · 짝 = `test_272_berm_source.py`.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const writes = [];
|
||||
const memory = () => {
|
||||
const m = new Map();
|
||||
return {
|
||||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||
setItem: (k, v) => {
|
||||
writes.push(k);
|
||||
m.set(k, String(v));
|
||||
},
|
||||
removeItem: (k) => m.delete(k),
|
||||
key: (i) => [...m.keys()][i] ?? null,
|
||||
get length() {
|
||||
return m.size;
|
||||
},
|
||||
clear: () => m.clear(),
|
||||
};
|
||||
};
|
||||
globalThis.window = globalThis;
|
||||
globalThis.sessionStorage = memory();
|
||||
globalThis.localStorage = memory();
|
||||
const any = new Proxy(function () {}, {
|
||||
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
|
||||
apply: () => any,
|
||||
construct: () => any,
|
||||
});
|
||||
globalThis.document = any;
|
||||
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
|
||||
globalThis.fetch = async () => new Response("{}", { status: 404 });
|
||||
|
||||
const m = await import(pathToFileURL(process.argv[2]).href);
|
||||
const job = JSON.parse(readFileSync(process.argv[3], "utf8"));
|
||||
const P = "p272berm";
|
||||
const R = 5;
|
||||
const out = {};
|
||||
|
||||
/* 1. 거울 — 구조물 → 소단 구간 · 저장분 제원 */
|
||||
out.spans = job.structureCases.map((list) => m.bermSpansFromStructures(list));
|
||||
out.stored = job.storedCases.map((spec) => m.storedBermSpec(spec));
|
||||
|
||||
/* 2. 시설 줄이 사본을 씀 — 구조물 받기 전 안 씀 · 같으면 안 씀 · 바뀌면 한 번 */
|
||||
{
|
||||
m.setPageRouteId(P, R);
|
||||
const key = m.stateKey("berm", P, R);
|
||||
const count = () => writes.filter((k) => k === key).length;
|
||||
const copy = () => m.readState("berm", P, R);
|
||||
const lane = m.createFacilityLane(P, () => null);
|
||||
const steps = {};
|
||||
lane.setTypes([]);
|
||||
lane.follow(); // 저장분 · 가게 구조물 없음 = 아직 모름
|
||||
steps.beforeKnown = { copy: copy(), writes: count() };
|
||||
const saved = job.structureCases[0];
|
||||
lane.setStored(saved);
|
||||
lane.set({ structures: saved }, false);
|
||||
steps.loaded = { copy: copy(), writes: count() };
|
||||
lane.set({ structures: saved.map((item) => ({ ...item })) });
|
||||
lane.set({ pipes: [] });
|
||||
steps.same = { writes: count() };
|
||||
const moved = saved.map((item) =>
|
||||
item.type_id === "berm" ? { ...item, start_m: item.start_m + 5, end_m: item.end_m + 5 } : item,
|
||||
);
|
||||
lane.set({ structures: moved });
|
||||
steps.moved = { copy: copy(), writes: count() };
|
||||
lane.set({ structures: [] });
|
||||
steps.removed = { copy: copy(), writes: count() };
|
||||
lane.dispose();
|
||||
out.lane = steps;
|
||||
}
|
||||
|
||||
/* 3. 다시 셈 소단 원천 — 사본 없음(저장분 design.berm) · 사본 = 같은 구간 · 사본 = 빈 목록 */
|
||||
{
|
||||
m.writeState("std-cross", job.standard, P);
|
||||
m.rememberRockBoundaryDefault(P, job.rockDefault);
|
||||
const detailOf = () => ({
|
||||
longitudinal: { length_m: 200, stations: [], profile_alignment: { policy: {} } },
|
||||
cross_sections: [
|
||||
{
|
||||
station_id: "station_000000040000",
|
||||
chainage_m: 40,
|
||||
label: "",
|
||||
kind: "regular",
|
||||
center_z: 96,
|
||||
azimuth_deg: 90,
|
||||
center_x: 40,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 1] },
|
||||
uphill_side: "left",
|
||||
samples: job.ground,
|
||||
design: {
|
||||
design_elevation_m: 100,
|
||||
ground_type: "soil",
|
||||
section_mode: "both_fill",
|
||||
ditch_type: "standard",
|
||||
berm: job.storedBerm,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const alignment = {
|
||||
stations: [{ chainage_m: 40, plan_elevation_m: 100 }],
|
||||
samples: [
|
||||
{ chainage_m: 0, elevation_m: 100 },
|
||||
{ chainage_m: 100000, elevation_m: 100 },
|
||||
],
|
||||
balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 },
|
||||
};
|
||||
const run = async (copy) => {
|
||||
if (copy === undefined) sessionStorage.removeItem(m.stateKey("berm", P, R));
|
||||
else m.writeState("berm", copy, P, R);
|
||||
const detail = detailOf();
|
||||
await m.refreshCrossDesigns({
|
||||
projectId: P,
|
||||
routeId: R,
|
||||
detail,
|
||||
edits: { station_offsets: {}, curve_radii: {} },
|
||||
alignment,
|
||||
});
|
||||
const design = detail.cross_sections[0].design;
|
||||
return { fill: design.fill_area_m2 ?? null, cut: design.cut_area_m2 ?? null };
|
||||
};
|
||||
out.refresh = {
|
||||
noCopy: await run(undefined),
|
||||
copySame: await run(m.bermSpansFromStructures(job.structureCases[0])),
|
||||
copyEmpty: await run([]),
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,7 @@
|
||||
/* 467 시험 묶음 진입점 — 소단 원천 한 벌(시설 줄 사본 · 다시 셈 저장분 폴백 · 진짜 모듈 · vite ssr). */
|
||||
export { createFacilityLane } from "../../A00_Common/design/design_facility_lane";
|
||||
export { setPageRouteId } from "../../A00_Common/design/design_page_route";
|
||||
export { refreshCrossDesigns, storedBermSpec } from "../../B06_Section/B06_Section_Cross_Refresh";
|
||||
export { bermSpansFromStructures } from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
export { rememberRockBoundaryDefault } from "../../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
export { readState, stateKey, writeState } from "../../A00_Common/b_page_state";
|
||||
@@ -0,0 +1,11 @@
|
||||
/* 272 시험 묶음 진입점(468 · 466-C) — 사진첩 빈 칸 · 세션 맵 빈 쓰기 · 숨은 종단도 파생값 · 배지 자릿수(진짜 모듈 · vite ssr). */
|
||||
export { createProfileHistory } from "../../B05_Profile/B05_Profile_UI_Profile_History";
|
||||
export {
|
||||
createSessionMap,
|
||||
moveSessionMaps,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Link_Session";
|
||||
export { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
|
||||
export { createRouteMassHaulPanel } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
export { createMassHaulBadge } from "../../common_util/common_util_mass_haul_badge";
|
||||
export { computeMassHaulSeries } from "../../common_util/common_util_mass_haul";
|
||||
export { staleKey } from "../../B06_Section/B06_Section_UI_Section_Common";
|
||||
@@ -0,0 +1,231 @@
|
||||
/* 467 헬퍼 — 노선 초안 없는 차례(관만 옮김)의 갈아끼움이 정본(서버) 횡단 기하 · 표본을 지키는지.
|
||||
* node helper_272_swap_canon.mjs <묶음.mjs>
|
||||
* 정본 = 서버 틀(표본 A) · 묶음 측점 = 브라우저 틀(중심 0.5 mm · R 12 곡선 축 3.3e-4 차 · 표본 B).
|
||||
* 짝 = `test_272_swap_keeps_canon.py` · 표준출력 JSON 한 벌.
|
||||
*/
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const memory = () => {
|
||||
const m = new Map();
|
||||
return {
|
||||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||
setItem: (k, v) => m.set(k, String(v)),
|
||||
removeItem: (k) => m.delete(k),
|
||||
key: (i) => [...m.keys()][i] ?? null,
|
||||
get length() {
|
||||
return m.size;
|
||||
},
|
||||
clear: () => m.clear(),
|
||||
};
|
||||
};
|
||||
globalThis.window = globalThis;
|
||||
globalThis.sessionStorage = memory();
|
||||
globalThis.localStorage = memory();
|
||||
const any = new Proxy(function () {}, {
|
||||
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
|
||||
apply: () => any,
|
||||
construct: () => any,
|
||||
});
|
||||
globalThis.document = any;
|
||||
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
|
||||
globalThis.fetch = async () => new Response("{}", { status: 404 });
|
||||
|
||||
const m = await import(pathToFileURL(process.argv[2]).href);
|
||||
const idOf = (c) => `station_${String(Math.round(c * 1000)).padStart(12, "0")}`;
|
||||
const OFFSETS = [-10, -6, -3, 0, 3, 6, 10];
|
||||
const groundA = (c, o) => 100 + 0.03 * c + 0.4 * o; // 서버 표본(정본)
|
||||
const groundB = (c, o) => groundA(c, o) + 0.0004 * Math.sin(c + o) + 0.0001; // 브라우저 지형(mm 차)
|
||||
|
||||
/* 측점 — [열쇠 · 누가거리 · 묶음 종류 · 상세 종류 · 축 차(rad)] · 곡선 셋 = R 12 */
|
||||
const LIST = [
|
||||
["end:BP", 0, "end", "bp", 1e-5],
|
||||
["grid:1", 20, "grid", "regular", 1e-5],
|
||||
["curve:27.50", 27.5, "curve", "curve", 3.3e-4],
|
||||
["curve:33.20", 33.2, "curve", "curve", -3.3e-4],
|
||||
["curve:38.90", 38.9, "curve", "curve", 2.1e-4],
|
||||
["grid:2", 40, "grid", "regular", 1e-5],
|
||||
["pipe:p1", 45, "pipe", "irregular", 1e-5],
|
||||
["grid:3", 60, "grid", "regular", 1e-5],
|
||||
["end:EP", 70, "end", "ep", 1e-5],
|
||||
];
|
||||
const azOf = (c) => 0.6 + c / 40; // 진행 방향(rad · 곡선 둘레가 휨)
|
||||
const leftOf = (a) => [-Math.sin(a), Math.cos(a)];
|
||||
const xyOf = (c) => [1000 + 12 * Math.sin(azOf(c)), 2000 - 12 * Math.cos(azOf(c))];
|
||||
|
||||
const canonSection = ([, c, , kind]) => {
|
||||
const [x, y] = xyOf(c);
|
||||
const samples = OFFSETS.map((o) => ({ offset_m: o, elevation_m: groundA(c, o), valid: true }));
|
||||
return {
|
||||
station_id: idOf(c),
|
||||
chainage_m: c,
|
||||
label: "",
|
||||
kind,
|
||||
center_z: groundA(c, 0),
|
||||
azimuth_deg: (azOf(c) * 180) / Math.PI,
|
||||
plan_radius_m: kind === "curve" ? 12 : null,
|
||||
curve_outer_side: kind === "curve" ? "left" : null,
|
||||
curve_widening_m: kind === "curve" ? 1.25 : null,
|
||||
center_x: x,
|
||||
center_y: y,
|
||||
frame: { left_xy: leftOf(azOf(c)) },
|
||||
uphill_side: "left",
|
||||
samples,
|
||||
design: { design_elevation_m: groundA(c, 0) + 0.3, mark: `canon@${c}` },
|
||||
...(kind === "irregular" ? { structure: "관", culvert: {} } : {}),
|
||||
};
|
||||
};
|
||||
const canonDetail = () => ({
|
||||
longitudinal: {
|
||||
length_m: 70,
|
||||
stations: [],
|
||||
profile_alignment: { policy: { station_interval_m: 20 } },
|
||||
},
|
||||
cross_sections: LIST.map(canonSection),
|
||||
});
|
||||
/** 묶음 측점 — 브라우저 틀(중심 0.5 mm · 축 차) · 표본 B · `shift` = 노선 초안이 옮긴 자리(m · y). */
|
||||
const station = ([key, c0, kind, , tilt], pipeAt, shift = () => 0) => {
|
||||
const c = key === "pipe:p1" ? pipeAt : c0;
|
||||
const [x, y] = xyOf(c);
|
||||
const a = azOf(c) + tilt;
|
||||
const side = (sign) =>
|
||||
OFFSETS.filter((o) => o * sign > 0).map((o) => ({ offsetM: Math.abs(o), z: groundB(c, o) }));
|
||||
return {
|
||||
key,
|
||||
chainageM: c,
|
||||
x: x + 0.0004,
|
||||
y: y - 0.0003 + shift(c),
|
||||
azimuthDeg: (a * 180) / Math.PI,
|
||||
left: leftOf(a),
|
||||
kind,
|
||||
id: key === "pipe:p1" ? "p1" : null,
|
||||
ground: { centerZ: groundB(c, 0), left: side(1), right: side(-1) },
|
||||
planRadiusM: kind === "curve" ? 12.0004 : null,
|
||||
curveOuterSide: kind === "curve" ? "left" : null,
|
||||
curveWideningM: kind === "curve" ? 1.2501 : null,
|
||||
};
|
||||
};
|
||||
const stationsAt = (pipeAt, shift) =>
|
||||
LIST.map((row) => station(row, pipeAt, shift)).sort((p, q) => p.chainageM - q.chainageM);
|
||||
const KEYS = LIST.map(([key]) => key);
|
||||
const keysOf = (list) => list.map((s) => s.key);
|
||||
const json = (v) => JSON.stringify(v);
|
||||
const canonOf = new Map(canonDetail().cross_sections.map((s) => [s.chainage_m, s]));
|
||||
/** 상세 측점마다 [정본과 같은가(기하 · 표본 · 중심고) · 표본이 브라우저 B 인가 · 설계 표시]. */
|
||||
const GEOM = ["center_x", "center_y", "frame", "azimuth_deg", "plan_radius_m", "curve_widening_m"];
|
||||
const report = (detail) =>
|
||||
Object.fromEntries(
|
||||
detail.cross_sections.map((s) => {
|
||||
const c = canonOf.get(s.chainage_m);
|
||||
const same =
|
||||
!!c &&
|
||||
GEOM.every((k) => json(s[k]) === json(c[k])) &&
|
||||
json(s.samples) === json(c.samples) &&
|
||||
s.center_z === c.center_z;
|
||||
const browser = s.samples.every(
|
||||
(p) => Math.abs(p.elevation_m - groundB(s.chainage_m, p.offset_m)) < 1e-12,
|
||||
);
|
||||
return [s.chainage_m.toFixed(2), { same, browser, mark: s.design?.mark ?? null }];
|
||||
}),
|
||||
);
|
||||
/** `skip` 밖 횡단이 정본과 바이트 같은가(이름 글은 갈아끼움이 새로 씀 · 뺌). */
|
||||
const bytesSame = (detail, skip) =>
|
||||
detail.cross_sections
|
||||
.filter((s) => !skip.includes(s.chainage_m))
|
||||
.every((s) => {
|
||||
const c = canonOf.get(s.chainage_m);
|
||||
if (!c) return false;
|
||||
const { label: _a, ...got } = s;
|
||||
const { label: _b, ...want } = c;
|
||||
return (
|
||||
json(
|
||||
Object.keys(got)
|
||||
.sort()
|
||||
.map((k) => [k, got[k]]),
|
||||
) ===
|
||||
json(
|
||||
Object.keys(want)
|
||||
.sort()
|
||||
.map((k) => [k, want[k]]),
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const out = {};
|
||||
|
||||
/* 1. 순수 — 관만 옮김(45 → 55) · 노선 초안 없음 대 있음 · 되돌림 */
|
||||
{
|
||||
const moved = stationsAt(55);
|
||||
const keep = m.swapCrossSections(canonDetail(), moved, KEYS, 20, false);
|
||||
const draft = m.swapCrossSections(canonDetail(), moved, KEYS, 20, true);
|
||||
const back = m.swapCrossSections(keep.detail, stationsAt(45), keysOf(moved), 20, false);
|
||||
out.pure = {
|
||||
keep: report(keep.detail),
|
||||
draft: report(draft.detail),
|
||||
keepMoved: keep.moved,
|
||||
back: report(back.detail),
|
||||
backBytes: bytesSame(back.detail, [45]),
|
||||
defaultIsKeep:
|
||||
json(m.swapCrossSections(canonDetail(), moved, KEYS, 20).detail) === json(keep.detail),
|
||||
};
|
||||
}
|
||||
|
||||
/* 1-2. 격자 측점(40 m)에 구조물을 놓고 뺌 — 열쇠가 바뀌어도 같은 자리 = 정본 표본 · 기하 */
|
||||
{
|
||||
const onGrid = stationsAt(45).map((s) =>
|
||||
s.key === "grid:2" ? { ...s, key: "structure:s1", kind: "structure", id: "s1" } : s,
|
||||
);
|
||||
const placed = m.swapCrossSections(canonDetail(), onGrid, KEYS, 20, false);
|
||||
const removed = m.swapCrossSections(placed.detail, stationsAt(45), keysOf(onGrid), 20, false);
|
||||
out.gridStructure = {
|
||||
placed: report(placed.detail)["40.00"],
|
||||
removed: report(removed.detail)["40.00"],
|
||||
removedBytes: bytesSame(removed.detail, [45, 40]),
|
||||
};
|
||||
}
|
||||
|
||||
/* 2. 가게 길(swapSectionStations) — 가게 routeDraft 로 가름: 없음 · 있음 · 초안 버린 직후 */
|
||||
const seed = (P, R) => {
|
||||
m.writeState("section-detail", canonDetail(), P, R);
|
||||
m.writeState("pipe-ids", { next: 2, ids: [{ id: "p1", at: 45 }] }, P);
|
||||
};
|
||||
const pipeAt = (P, at) => m.writeState("pipe-ids", { next: 2, ids: [{ id: "p1", at }] }, P);
|
||||
const keyList = () => KEYS.map((key, i) => ({ key, chainageM: LIST[i][1] }));
|
||||
{
|
||||
const P = "p272a";
|
||||
const R = 3;
|
||||
seed(P, R);
|
||||
await m.loadSectionDetail(P, R);
|
||||
m.useStationKeyList(P, R, keyList());
|
||||
pipeAt(P, 55);
|
||||
const moved = await m.swapSectionStations(P, R, stationsAt(55));
|
||||
pipeAt(P, 45);
|
||||
const back = await m.swapSectionStations(P, R, stationsAt(45));
|
||||
out.storeNoDraft = {
|
||||
moved: moved && report(moved.detail),
|
||||
back: back && report(back.detail),
|
||||
backBytes: !!back && bytesSame(back.detail, [45]),
|
||||
};
|
||||
}
|
||||
{
|
||||
const P = "p272b";
|
||||
const R = 4;
|
||||
seed(P, R);
|
||||
await m.loadSectionDetail(P, R);
|
||||
m.useStationKeyList(P, R, keyList());
|
||||
const shop = m.useDesignStore(P, null);
|
||||
shop.set("routeDraft", { nodes: [], rev: 1 });
|
||||
pipeAt(P, 55);
|
||||
// 초안 = 곡선 둘레만 3 cm 옮김(나머지 자리 그대로)
|
||||
const bend = (c) => (c > 25 && c < 39 ? 0.03 : 0);
|
||||
const draft = await m.swapSectionStations(P, R, stationsAt(55, bend));
|
||||
shop.set("routeDraft", null); // 초안 버림 → 저장 노선 측점(묶음 「no-saved」 길)
|
||||
pipeAt(P, 45);
|
||||
const dropped = await m.swapSectionStations(P, R, stationsAt(45));
|
||||
out.storeDraft = {
|
||||
routeDraftSet: draft !== null,
|
||||
draft: draft && report(draft.detail),
|
||||
dropped: dropped && report(dropped.detail),
|
||||
};
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,9 @@
|
||||
/* 467 시험 묶음 진입점 — 갈아끼움 정본 표본 유지(진짜 모듈 · vite ssr). */
|
||||
export { swapCrossSections } from "../../B06_Section/logic/B06_Section_Station_Swap";
|
||||
export {
|
||||
loadSectionDetail,
|
||||
swapSectionStations,
|
||||
} from "../../B06_Section/B06_Section_Section_Store";
|
||||
export { useStationKeyList } from "../../B06_Section/B06_Section_Station_Keys";
|
||||
export { useDesignStore } from "../../A00_Common/design/design_store";
|
||||
export { writeState } from "../../A00_Common/b_page_state";
|
||||
@@ -0,0 +1,353 @@
|
||||
/* 272 헬퍼(468 · 466-C) — 사진첩 빈 칸 · 세션 맵 빈 쓰기 · 숨은 종단도 파생값 · 배지 자릿수.
|
||||
* node helper_272_value_hold.mjs <묶음.mjs>
|
||||
* vite ssr 로 묶은 진짜 모듈(helper_272_entry.ts) · 표준출력 JSON 한 벌.
|
||||
* 짝 = `test_272_history_empty_cells.py` · `test_272_hidden_summary.py`.
|
||||
*/
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const store = () => {
|
||||
const m = new Map();
|
||||
return {
|
||||
getItem: (k) => (m.has(k) ? m.get(k) : null),
|
||||
setItem: (k, v) => m.set(k, String(v)),
|
||||
removeItem: (k) => m.delete(k),
|
||||
key: (i) => [...m.keys()][i] ?? null,
|
||||
get length() {
|
||||
return m.size;
|
||||
},
|
||||
clear: () => m.clear(),
|
||||
};
|
||||
};
|
||||
// 손길 듣기(pointerdown · keydown)가 붙는 창 — 사진첩 시험(346-4)과 같은 꼴
|
||||
const win = new EventTarget();
|
||||
win.setTimeout = setTimeout;
|
||||
win.clearTimeout = clearTimeout;
|
||||
win.sessionStorage = store();
|
||||
win.localStorage = store();
|
||||
win.innerHeight = 900;
|
||||
win.matchMedia = () => ({ matches: false, addEventListener: () => undefined });
|
||||
win.requestAnimationFrame = (run) => setTimeout(run, 0);
|
||||
globalThis.window = win;
|
||||
globalThis.screen = {};
|
||||
globalThis.sessionStorage = win.sessionStorage;
|
||||
globalThis.localStorage = win.localStorage;
|
||||
globalThis.requestAnimationFrame = win.requestAnimationFrame;
|
||||
const any = new Proxy(function () {}, {
|
||||
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
|
||||
apply: () => any,
|
||||
construct: () => any,
|
||||
});
|
||||
globalThis.document = any;
|
||||
localStorage.setItem("frd_current_project_id", "p1");
|
||||
|
||||
const m = await import(pathToFileURL(process.argv[2]).href);
|
||||
const out = {};
|
||||
const S = sessionStorage;
|
||||
const key = (name) => `aislo:draft:${name}:p1:7`;
|
||||
const SIX = ["basinadjust", "crossw", "extraspan", "extrawall", "inletstruct", "revetx"];
|
||||
const touch = () => win.dispatchEvent(new Event("keydown"));
|
||||
|
||||
/* ── H1 · 되돌린 뒤 빈 칸이 생김(R4) — 「{}」 · 「null」 · 「""」 쓰기는 걸음 아님 · ↷ 남음 ── */
|
||||
{
|
||||
const h = m.createProfileHistory(() => {});
|
||||
touch();
|
||||
S.setItem(key("alignment-draft"), JSON.stringify({ station_offsets: { 100: 1 } }));
|
||||
h.record();
|
||||
const edited = h.canUndo();
|
||||
h.undo();
|
||||
const undone = { undo: h.canUndo(), redo: h.canRedo(), grade: S.getItem(key("alignment-draft")) };
|
||||
// B06 로 넘어가는 누름(손길) 뒤 첫 마운트 · 갈아끼움 이동(moveSessionMaps)이 빈 맵 여섯을 씀 · 다른 칸은 null · 빈 글
|
||||
touch();
|
||||
SIX.forEach((name) => S.setItem(key(name), "{}"));
|
||||
S.setItem(key("rockb"), "null");
|
||||
S.setItem(key("cutslope"), "");
|
||||
h.record();
|
||||
out.h1 = { edited, undone, after: { undo: h.canUndo(), redo: h.canRedo() } };
|
||||
// ↷ = 사용자 조작 그대로 한 걸음
|
||||
out.h1.redone = h.redo();
|
||||
out.h1.redoGrade = S.getItem(key("alignment-draft"));
|
||||
out.h1.final = { undo: h.canUndo(), redo: h.canRedo() };
|
||||
S.clear();
|
||||
localStorage.setItem("frd_current_project_id", "p1");
|
||||
}
|
||||
|
||||
/* ── H2 · 「[]」 = 목록 초안(관 · 구조물 모두 지움) — 걸음 그대로 · 되돌리면 칸 없음 ── */
|
||||
{
|
||||
const h = m.createProfileHistory(() => {});
|
||||
touch();
|
||||
S.setItem("aislo:draft:pipes:p1", "[]");
|
||||
h.record();
|
||||
const step = h.canUndo();
|
||||
h.undo();
|
||||
out.h2 = { step, undonePipes: S.getItem("aislo:draft:pipes:p1") };
|
||||
h.redo();
|
||||
out.h2.redonePipes = S.getItem("aislo:draft:pipes:p1");
|
||||
S.clear();
|
||||
localStorage.setItem("frd_current_project_id", "p1");
|
||||
}
|
||||
|
||||
/* ── H3 · 빈 칸이 있던 걸음으로 되돌림 = 빈 칸(없음과 같음) · 값 칸은 그대로 ── */
|
||||
{
|
||||
S.setItem(key("crossw"), "{}");
|
||||
S.setItem(key("revetx"), JSON.stringify({ "100.00:inlet": { x: 1 } }));
|
||||
const h = m.createProfileHistory(() => {});
|
||||
touch();
|
||||
S.setItem(key("crossw"), JSON.stringify({ "100.00": 6 }));
|
||||
h.record();
|
||||
const step = h.canUndo();
|
||||
h.undo();
|
||||
out.h3 = { step, crossw: S.getItem(key("crossw")), revetx: S.getItem(key("revetx")) };
|
||||
S.clear();
|
||||
localStorage.setItem("frd_current_project_id", "p1");
|
||||
}
|
||||
|
||||
/* ── H4 · 세션 맵 — 빈 맵 쓰기 = 칸 지움 · 값 있으면 씀 · 빈 맵 이동은 칸을 안 만듦 ── */
|
||||
{
|
||||
const width = m.createSessionMap(
|
||||
() => key("crossw"),
|
||||
(v) => (typeof v === "number" ? v : undefined),
|
||||
);
|
||||
width.persist();
|
||||
const emptyWrite = S.getItem(key("crossw"));
|
||||
width.values.set("100.00", 6);
|
||||
width.persist();
|
||||
const filledWrite = S.getItem(key("crossw"));
|
||||
width.values.clear();
|
||||
width.persist();
|
||||
const clearedWrite = S.getItem(key("crossw"));
|
||||
const others = SIX.filter((n) => n !== "crossw").map((n) =>
|
||||
m.createSessionMap(
|
||||
() => key(n),
|
||||
(v) => v,
|
||||
),
|
||||
);
|
||||
m.moveSessionMaps(new Map([["100.00", "120.00"]]));
|
||||
const moved = SIX.map((n) => S.getItem(key(n)));
|
||||
// 값 있는 맵 이동 = 새 자리로 옮겨 씀
|
||||
S.setItem(key("extrawall"), JSON.stringify({ "100.00": 2 }));
|
||||
m.moveSessionMaps(new Map([["100.00", "120.00"]]));
|
||||
out.h4 = {
|
||||
emptyWrite,
|
||||
filledWrite,
|
||||
clearedWrite,
|
||||
moved,
|
||||
movedFilled: S.getItem(key("extrawall")),
|
||||
made: others.length,
|
||||
};
|
||||
S.clear();
|
||||
localStorage.setItem("frd_current_project_id", "p1");
|
||||
}
|
||||
|
||||
/* ── 숨은 종단도(본문 0 크기) — 낡음 예약 · 유토곡선 셈 · 배분 선반입 여전히 · 펼침과 같은 값 ── */
|
||||
const samples = [0, 20, 40, 60, 80, 100].map((c) => ({
|
||||
chainage_m: c,
|
||||
elevation_m: 100 + c * 0.05,
|
||||
valid: true,
|
||||
}));
|
||||
const plan = [0, 20, 40, 60, 80, 100].map((c) => ({ chainage_m: c, elevation_m: 100 + c * 0.04 }));
|
||||
const design = (c, cut, fill, z) => ({
|
||||
station_id: `station_${c}`,
|
||||
chainage_m: c,
|
||||
design: {
|
||||
cut_area_m2: cut,
|
||||
fill_area_m2: fill,
|
||||
ground_type: "soil",
|
||||
design_elevation_m: z,
|
||||
},
|
||||
});
|
||||
const detail = {
|
||||
longitudinal: {
|
||||
length_m: 100,
|
||||
samples,
|
||||
stations: samples.map((s) => ({
|
||||
station_id: `station_${s.chainage_m}`,
|
||||
chainage_m: s.chainage_m,
|
||||
})),
|
||||
design_profiles: [{ id: "design_grade_line", samples: plan }],
|
||||
},
|
||||
cross_sections: [
|
||||
design(0, 2, 0, 100),
|
||||
design(20, 3, 1, 100.8),
|
||||
design(40, 1, 4, 101.6),
|
||||
design(60, 0, 6, 999), // 낡음 — 계획선과 어긋난 측점(다시 셈 예약 대상)
|
||||
design(80, 2, 2, 103.2),
|
||||
design(100, 4, 0, 104),
|
||||
],
|
||||
};
|
||||
const alignment = {
|
||||
samples: plan,
|
||||
edits: { station_offsets: {}, curve_radii: {} },
|
||||
stations: plan.map((p) => ({
|
||||
chainage_m: p.chainage_m,
|
||||
plan_elevation_m: p.elevation_m,
|
||||
kind: "regular",
|
||||
})),
|
||||
curves: [],
|
||||
balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 },
|
||||
policy: { station_interval_m: 20, edit_step_m: 0.1 },
|
||||
};
|
||||
const ONES = {
|
||||
soil: { loose: 1, compacted: 1 },
|
||||
ripping_rock: { loose: 1, compacted: 1 },
|
||||
blasting_rock: { loose: 1, compacted: 1 },
|
||||
};
|
||||
|
||||
function ctxFor(width, rec) {
|
||||
const body = {
|
||||
clientWidth: width,
|
||||
clientHeight: width ? 400 : 0,
|
||||
scrollLeft: 0,
|
||||
parentElement: null,
|
||||
replaceChildren: () => (rec.painted += 1),
|
||||
addEventListener: () => undefined,
|
||||
};
|
||||
return {
|
||||
body,
|
||||
onStaleDesigns: (k) => (rec.stale.push(k), false),
|
||||
callbacks: undefined,
|
||||
massHaul: { draw: (p) => rec.mass.push(p) },
|
||||
tableOverlay: { isOpen: () => false, contentHeight: () => 0, setTable: () => undefined },
|
||||
store: { edits: () => ({}), savedRadii: () => ({}), resetStation: () => undefined },
|
||||
detail: () => detail,
|
||||
alignment: () => alignment,
|
||||
base: () => null,
|
||||
stationInterval: () => 20,
|
||||
irregularStations: () => [],
|
||||
minCoverTargets: () => [],
|
||||
structures: () => [],
|
||||
structureTypes: () => [],
|
||||
selectedStationId: () => null,
|
||||
setSelectedStationId: () => undefined,
|
||||
selectedStructureId: () => null,
|
||||
setSelectedStructureId: () => undefined,
|
||||
stationDisplay: () => ({ station: 0, cumulative: 0 }),
|
||||
setLastSize: () => (rec.sized += 1),
|
||||
renderBalance: () => (rec.balance += 1),
|
||||
applyHeightCascade: () => ({ chartHeight: 300 }),
|
||||
clearMainDragCooldown: () => undefined,
|
||||
selectStation: () => undefined,
|
||||
applyEdits: () => undefined,
|
||||
handleToolPick: () => false,
|
||||
zoom: () => ({ x: 1 }),
|
||||
holdElevationRange: (next) => next ?? undefined,
|
||||
toolActive: () => false,
|
||||
tools: { selectedRuns: () => [], mountOverlay: () => undefined },
|
||||
stationIdAtStructure: () => null,
|
||||
redraw: () => undefined,
|
||||
setElevationWindowUpdater: () => undefined,
|
||||
};
|
||||
}
|
||||
const pick = (p) =>
|
||||
p && {
|
||||
pendingRecalc: p.pendingRecalc,
|
||||
staleChainages: p.staleChainages,
|
||||
crossSections: p.crossSections.length,
|
||||
longitudinal: p.longitudinal,
|
||||
stations: p.stationSource.stations.map((s) => s.chainage_m),
|
||||
maxChainageM: p.axis.maxChainageM,
|
||||
};
|
||||
const hiddenRec = { stale: [], mass: [], painted: 0, sized: 0, balance: 0 };
|
||||
m.renderProfile(ctxFor(0, hiddenRec));
|
||||
const shownRec = { stale: [], mass: [], painted: 0, sized: 0, balance: 0 };
|
||||
let shownError = null;
|
||||
try {
|
||||
m.renderProfile(ctxFor(1200, shownRec));
|
||||
} catch (error) {
|
||||
shownError = String(error?.stack ?? error);
|
||||
}
|
||||
out.hidden = {
|
||||
stale: hiddenRec.stale,
|
||||
mass: pick(hiddenRec.mass[0]),
|
||||
massCount: hiddenRec.mass.length,
|
||||
painted: hiddenRec.painted,
|
||||
sized: hiddenRec.sized,
|
||||
};
|
||||
out.shown = {
|
||||
stale: shownRec.stale,
|
||||
mass: pick(shownRec.mass[0]),
|
||||
massCount: shownRec.mass.length,
|
||||
painted: shownRec.painted,
|
||||
error: shownError,
|
||||
};
|
||||
out.expectedStale = m.staleKey({
|
||||
longitudinal: { design_profiles: [{ samples: plan }] },
|
||||
cross_sections: detail.cross_sections,
|
||||
});
|
||||
|
||||
/* 유토곡선 판(진짜) — 판 접힘 · 펼침인데 본문 숨음(자리 0) = 총괄값 · 배분 선반입만 · 그림 없음 */
|
||||
function massPanel(open) {
|
||||
localStorage.setItem("aislo:pref:masshaul-open", String(open));
|
||||
const rec = { summary: [], scheduled: 0 };
|
||||
const prefetch = {
|
||||
schedule: () => (rec.scheduled += 1),
|
||||
current: () => null,
|
||||
};
|
||||
const panel = m.createRouteMassHaulPanel(
|
||||
() => undefined,
|
||||
(s) => rec.summary.push(s),
|
||||
prefetch,
|
||||
);
|
||||
panel.setContext({ conversion: ONES });
|
||||
return { panel, rec };
|
||||
}
|
||||
const series = m.computeMassHaulSeries(
|
||||
{ length_m: 100, design_profiles: [{ samples: plan }] },
|
||||
detail.cross_sections,
|
||||
ONES,
|
||||
undefined,
|
||||
["cross"],
|
||||
);
|
||||
const finalM3 = series[0]?.result.points.at(-1)?.cumulative_volume_m3 ?? null;
|
||||
const massParams = hiddenRec.mass[0];
|
||||
out.panel = {};
|
||||
for (const open of [false, true]) {
|
||||
const { panel, rec } = massPanel(open);
|
||||
let error = null;
|
||||
try {
|
||||
if (massParams) panel.draw({ ...massParams, pendingRecalc: false });
|
||||
} catch (e) {
|
||||
error = String(e?.stack ?? e);
|
||||
}
|
||||
out.panel[open ? "open" : "closed"] = {
|
||||
summary: rec.summary.map((s) => s?.finalM3 ?? null),
|
||||
scheduled: rec.scheduled,
|
||||
error,
|
||||
};
|
||||
}
|
||||
out.finalM3 = finalM3;
|
||||
|
||||
/* ── 배지 표기 — 소수 한 자리 고정 ── */
|
||||
{
|
||||
const make = () => {
|
||||
const el = {
|
||||
textContent: "",
|
||||
title: "",
|
||||
hidden: false,
|
||||
style: { setProperty: () => undefined },
|
||||
classList: { toggle: () => undefined, add: () => undefined },
|
||||
append: () => undefined,
|
||||
className: "",
|
||||
};
|
||||
return el;
|
||||
};
|
||||
const made = [];
|
||||
globalThis.document = { createElement: () => (made.push(make()), made.at(-1)) };
|
||||
const badge = m.createMassHaulBadge();
|
||||
const texts = [];
|
||||
for (const v of [-17570, -17570.7, 1234.56, 0]) {
|
||||
badge.set({
|
||||
finalM3: v,
|
||||
cutNaturalM3: 0,
|
||||
cutCompactedM3: 0,
|
||||
fillCompactedM3: 0,
|
||||
surplusM3: 0,
|
||||
shortageM3: 0,
|
||||
});
|
||||
texts.push(made[2].textContent);
|
||||
}
|
||||
out.badge = texts;
|
||||
globalThis.document = any;
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,200 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""467 (요구 272 · 분석 4장 (B2)) — 소단 원천 한 벌.
|
||||
|
||||
못박는 것
|
||||
- 소단 사본(세션 `berm`) 쓰는 자리 = 페이지 시설 줄 하나 — 구조물 받기 전 안 씀 ·
|
||||
같으면 안 씀 · 바뀌면 한 번 · 지우면 빈 목록(B05 만 열어도 씀 · 횡단 보기는 안 씀).
|
||||
- 다시 셈 = 사본 없으면 저장분 `design.berm`(파이썬 `load_berm_spans` 규칙) —
|
||||
B05 만 연 값 = B06 연 뒤 값 = [저장] 서버 값(`enforce_design_lines`) · 빈 사본 = 소단 없음.
|
||||
- 거울 — TS `bermSpansFromStructures` ↔ 파이썬 `load_berm_spans` ·
|
||||
TS `storedBermSpec` ↔ 파이썬 `berm_from`.
|
||||
|
||||
Node 헬퍼 `helper_272_berm_source.mjs` 가 vite ssr 로 묶은 진짜 모듈을 부름.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Engine_Structures_Wall import load_berm_spans # noqa: E402
|
||||
from B06_Section.B06_Section_Router_Design import berm_from, enforce_design_lines # noqa: E402
|
||||
from config.config_system import ( # noqa: E402
|
||||
STANDARD_CROSS_SECTION,
|
||||
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
)
|
||||
|
||||
|
||||
def _berm(start, end, **options):
|
||||
return {
|
||||
"type_id": "berm",
|
||||
"placement": "interval",
|
||||
"start_m": start,
|
||||
"end_m": end,
|
||||
"options": options,
|
||||
}
|
||||
|
||||
|
||||
SAVED = [_berm(0.0, 100.0, width_m=1.0, interval_m=3.0, slope_deg=0.0, depth_m=1.0)]
|
||||
STRUCTURE_CASES = [
|
||||
SAVED,
|
||||
[
|
||||
_berm(50.0, 80.0, width_m=0), # 폭 0 = 없음
|
||||
_berm(10.0, 30.0, depth_m=""), # 빈 깊이 = 기본 1.0
|
||||
_berm(30.0, 40.0, depth_m=-2, width_m="0.7"), # 음수 깊이 = 0 · 글 수
|
||||
_berm(60.0, 70.0, width_m="abc", interval_m=2.5, slope_deg=4), # 못 읽는 폭 = 기본
|
||||
],
|
||||
[{"type_id": "berm", "placement": "point", "chainage_m": 25.0, "options": {}}],
|
||||
[],
|
||||
]
|
||||
STORED_CASES = [
|
||||
{"width_m": 0.5},
|
||||
{"width_m": 0.5, "depth_m": -1},
|
||||
{},
|
||||
{"width_m": "0.7", "interval_m": 2},
|
||||
None,
|
||||
{"width_m": None},
|
||||
{"width_m": ""},
|
||||
]
|
||||
GROUND = [
|
||||
{"offset_m": o / 2, "elevation_m": round(96.0 + 0.02 * o / 2, 4), "valid": True}
|
||||
for o in range(-40, 41)
|
||||
]
|
||||
STORED_BERM = {"width_m": 1.0, "interval_m": 3.0, "slope_deg": 0.0, "depth_m": 1.0}
|
||||
LONG = {
|
||||
"design_profiles": [
|
||||
{
|
||||
"samples": [
|
||||
{"chainage_m": 0, "elevation_m": 100},
|
||||
{"chainage_m": 200, "elevation_m": 100},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _project(root: Path, structures: list[dict]) -> Path:
|
||||
route = root / "B05_Profile" / "route"
|
||||
route.mkdir(parents=True)
|
||||
(route / "structures.json").write_text(
|
||||
json.dumps({"revision": 1, "structures": structures}, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory):
|
||||
work = tmp_path_factory.mktemp("b272berm")
|
||||
build = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../resources/tester/helper_272_berm_source_entry.ts",
|
||||
"--outDir",
|
||||
str(work),
|
||||
"--logLevel",
|
||||
"error",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
)
|
||||
assert build.returncode == 0, build.stderr
|
||||
bundle = work / "bundle.mjs"
|
||||
shutil.copy(work / "helper_272_berm_source_entry.js", bundle)
|
||||
job = work / "job.json"
|
||||
job.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"standard": STANDARD_CROSS_SECTION,
|
||||
"rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
"structureCases": STRUCTURE_CASES,
|
||||
"storedCases": STORED_CASES,
|
||||
"ground": GROUND,
|
||||
"storedBerm": STORED_BERM,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run( # noqa: S603
|
||||
["node", str(HERE / "helper_272_berm_source.mjs"), str(bundle), str(job)],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_거울_구조물_소단_구간(out, tmp_path):
|
||||
for index, (structures, ts) in enumerate(zip(STRUCTURE_CASES, out["spans"], strict=True)):
|
||||
py = load_berm_spans(_project(tmp_path / f"c{index}", structures))
|
||||
assert py is not None, index
|
||||
want = [{"start_m": s, "end_m": e, **spec} for s, e, spec in py]
|
||||
assert len(ts) == len(want), (index, ts, want)
|
||||
for got, exp in zip(ts, want, strict=True):
|
||||
assert got == pytest.approx(exp), (index, got, exp)
|
||||
|
||||
|
||||
def test_거울_저장분_소단_제원(out):
|
||||
for spec, ts in zip(STORED_CASES, out["stored"], strict=True):
|
||||
py = berm_from(spec, {})
|
||||
if py is None:
|
||||
assert ts is None, spec
|
||||
continue
|
||||
want = {k: getattr(py, k) for k in ("width_m", "interval_m", "slope_deg", "depth_m")}
|
||||
assert ts == pytest.approx(want), spec
|
||||
|
||||
|
||||
def test_시설_줄이_사본을_씀(out):
|
||||
lane = out["lane"]
|
||||
assert lane["beforeKnown"] == {"copy": None, "writes": 0} # 구조물 받기 전 = 안 씀
|
||||
assert lane["loaded"]["writes"] == 1
|
||||
assert lane["loaded"]["copy"] == out["spans"][0] # 저장 구조물 소단 구간
|
||||
assert lane["same"]["writes"] == 1 # 같은 구간 · 관만 알림 = 안 씀
|
||||
assert lane["moved"]["writes"] == 2
|
||||
assert lane["moved"]["copy"][0]["start_m"] == 5.0 and lane["moved"]["copy"][0]["end_m"] == 105.0
|
||||
assert lane["removed"] == {
|
||||
"copy": [],
|
||||
"writes": 3,
|
||||
} # 소단 지움 = 빈 사본(저장분 소단으로 안 물러섬)
|
||||
|
||||
|
||||
def test_다시_셈_B05만_B06_저장_같은_값(out, tmp_path):
|
||||
r = out["refresh"]
|
||||
no_copy, same, empty = r["noCopy"], r["copySame"], r["copyEmpty"]
|
||||
assert no_copy["fill"] == same["fill"] and no_copy["cut"] == same["cut"] # B05 만 = B06 연 뒤
|
||||
assert empty["fill"] != no_copy["fill"] # 빈 사본 = 소단 없음(저장분으로 안 물러섬)
|
||||
# [저장] = 서버가 구조물 정본으로 다시 셈
|
||||
root = _project(tmp_path / "save", SAVED)
|
||||
design = {"ground_type": "soil", "section_mode": "both_fill", "ditch_type": "standard"}
|
||||
sections = [{"chainage_m": 40.0, "samples": GROUND, "design": design}]
|
||||
assert enforce_design_lines(LONG, sections, root) == 1
|
||||
assert abs(sections[0]["design"]["fill_area_m2"] - same["fill"]) < 1e-3
|
||||
bare = [{"chainage_m": 40.0, "samples": GROUND, "design": dict(design)}]
|
||||
enforce_design_lines(LONG, bare, _project(tmp_path / "bare", []))
|
||||
assert abs(bare[0]["design"]["fill_area_m2"] - empty["fill"]) < 1e-3
|
||||
|
||||
|
||||
def test_쓰는_자리_하나():
|
||||
sync = (ROOT / "B06_Section" / "B06_Section_UI_Page_Design_Sync.ts").read_text(encoding="utf-8")
|
||||
assert "writeBermSpans" not in sync and "useStructureIds(projectId, structures)" in sync
|
||||
lane = (ROOT / "A00_Common" / "design" / "design_facility_lane.ts").read_text(encoding="utf-8")
|
||||
assert "writeBermSpans(projectId, routeId, next)" in lane
|
||||
@@ -0,0 +1,55 @@
|
||||
"""272 · 468 (C2) 종단 판 접힘(본문 0 크기) 파생값 · (C3) 배지 자릿수.
|
||||
|
||||
R3 — 판 접은 채 교점 ↓ → 배지 옛 값 그대로 → 이제 접힘 동안도 낡음 예약 · 유토 총괄값 · 배분 선반입 · 측점 자료를 셈 ·
|
||||
그리기(본문 갈아 끼움 · 크기 기록)만 건너뜀 · 펼친 그림과 같은 값. 배지 = 소수 한 자리 고정.
|
||||
짝: `helper_272_value_hold.mjs` · 묶음 짓기 = `test_272_history_empty_cells.run_helper`.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_272_hidden_summary.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from test_272_history_empty_cells import pytestmark, run_helper # noqa: E402, F401
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory):
|
||||
return run_helper(tmp_path_factory)
|
||||
|
||||
|
||||
def test_숨은_본문도_낡음_예약은_펼침과_같은_열쇠(out):
|
||||
assert out["shown"]["error"] is None
|
||||
assert out["shown"]["stale"] == [out["expectedStale"]]
|
||||
assert out["hidden"]["stale"] == out["shown"]["stale"]
|
||||
|
||||
|
||||
def test_숨은_본문도_유토곡선_입력은_펼침과_같음(out):
|
||||
hidden, shown = out["hidden"], out["shown"]
|
||||
assert hidden["massCount"] == shown["massCount"] == 1
|
||||
assert hidden["mass"] == shown["mass"]
|
||||
assert hidden["mass"]["staleChainages"] == [60]
|
||||
|
||||
|
||||
def test_숨은_본문은_그리기만_건너뜀(out):
|
||||
assert out["hidden"]["painted"] == 0 and out["hidden"]["sized"] == 0
|
||||
assert out["shown"]["painted"] == 1
|
||||
|
||||
|
||||
def test_유토_판_접힘_펼침_모두_총괄값과_배분_선반입(out):
|
||||
final = out["finalM3"]
|
||||
assert final is not None
|
||||
for state in ("closed", "open"):
|
||||
panel = out["panel"][state]
|
||||
assert panel["error"] is None, panel["error"]
|
||||
assert panel["summary"] == [final], state
|
||||
assert panel["scheduled"] == 1, state
|
||||
|
||||
|
||||
def test_배지는_소수_한_자리(out):
|
||||
assert out["badge"] == ["-17,570.0㎥", "-17,570.7㎥", "1,234.6㎥", "0.0㎥"]
|
||||
@@ -0,0 +1,104 @@
|
||||
"""272 · 468 (C1) 되돌리기 빈 칸 — 「{}」 · 「null」 · 「""」 = 칸 없음 · 세션 맵 빈 쓰기 = 칸 지움.
|
||||
|
||||
R4 — 교점 ↓ → ↶ → B06(누름 뒤 첫 마운트가 빈 맵 여섯 「{}」를 씀) → ↶ 켜짐 · ↷ 사라짐 → 이제 ↶ 꺼짐 · ↷ 남음.
|
||||
「[]」 는 목록 초안(관 · 구조물 모두 지움 = 저장분과 다름)이라 걸음 그대로.
|
||||
짝: `helper_272_value_hold.mjs`(vite ssr 로 묶은 진짜 모듈 · `helper_272_entry.ts`).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_272_history_empty_cells.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "vite").is_dir(),
|
||||
reason="프론트엔드 의존성 미설치",
|
||||
)
|
||||
|
||||
|
||||
def run_helper(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
work = tmp_path_factory.mktemp("b272")
|
||||
build = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../resources/tester/helper_272_entry.ts",
|
||||
"--outDir",
|
||||
str(work),
|
||||
"--logLevel",
|
||||
"error",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
)
|
||||
assert build.returncode == 0, build.stderr
|
||||
bundle = work / "bundle.mjs"
|
||||
shutil.copy(work / "helper_272_entry.js", bundle)
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_272_value_hold.mjs"), str(bundle)],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory):
|
||||
return run_helper(tmp_path_factory)
|
||||
|
||||
|
||||
def test_되돌린_뒤_빈_칸이_생겨도_다시하기_남음(out):
|
||||
h1 = out["h1"]
|
||||
assert h1["edited"] is True
|
||||
assert h1["undone"] == {"undo": False, "redo": True, "grade": None}
|
||||
# 빈 맵 여섯 「{}」 · 「null」 · 「""」 = 걸음 아님
|
||||
assert h1["after"] == {"undo": False, "redo": True}
|
||||
|
||||
|
||||
def test_사용자_조작은_한_걸음_그대로(out):
|
||||
h1 = out["h1"]
|
||||
assert h1["redone"] is True
|
||||
assert h1["redoGrade"] == json.dumps({"station_offsets": {"100": 1}}, separators=(",", ":"))
|
||||
assert h1["final"] == {"undo": True, "redo": False}
|
||||
|
||||
|
||||
def test_빈_목록_초안은_걸음(out):
|
||||
# 관 모두 지움(「[]」) ≠ 칸 없음(저장분 관) — 되돌리기 · 다시하기 그대로
|
||||
assert out["h2"] == {"step": True, "undonePipes": None, "redonePipes": "[]"}
|
||||
|
||||
|
||||
def test_빈_칸_걸음으로_되돌리면_빈_칸(out):
|
||||
h3 = out["h3"]
|
||||
assert h3["step"] is True
|
||||
assert h3["crossw"] in (None, "{}") # 빈 칸 = 칸 없음(읽는 쪽은 빈 맵)
|
||||
assert h3["revetx"] == json.dumps({"100.00:inlet": {"x": 1}}, separators=(",", ":"))
|
||||
|
||||
|
||||
def test_세션_맵_빈_쓰기는_칸_지움(out):
|
||||
h4 = out["h4"]
|
||||
assert h4["emptyWrite"] is None
|
||||
assert h4["filledWrite"] == '{"100.00":6}'
|
||||
assert h4["clearedWrite"] is None
|
||||
# 빈 맵 이동은 칸을 안 만듦 · 값 있는 맵은 새 자리로 옮겨 씀
|
||||
assert h4["moved"] == [None] * 6
|
||||
assert h4["movedFilled"] == '{"120.00":2}'
|
||||
@@ -0,0 +1,118 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""467 (요구 272 · 분석 4장 (B1)) — 노선 초안 없는 차례의 갈아끼움이 정본 횡단을 지킴.
|
||||
|
||||
못박는 것
|
||||
- 관만 옮김(노선 초안 없음) — 같은 열쇠 · 누가거리 0.01 m 안 측점은 앞 횡단(서버 정본)
|
||||
기하 · 표본 · 중심고 그대로(R 12 곡선 축 차 3.3e-4 · 중심 0.5 mm 차여도) ·
|
||||
옮긴 관 측점만 브라우저 표본.
|
||||
- 되돌림(관 제자리) — 관 측점 밖 모든 횡단이 정본과 바이트 같음(관 측점 = 브라우저 표본).
|
||||
- 노선 초안 차례 = 옛 규칙(축 허용 1e-4 · 넘으면 브라우저 표본).
|
||||
- 가게 길(`swapSectionStations`) = 가게 `routeDraft` 로 가름 — 없음 · 있음 ·
|
||||
초안 버린 직후(초안 표본이 안 남음).
|
||||
|
||||
Node 헬퍼 `helper_272_swap_canon.mjs` 가 vite ssr 로 묶은 진짜 모듈을 부름.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
CURVES = ("27.50", "33.20", "38.90")
|
||||
STRAIGHT = ("0.00", "20.00", "40.00", "60.00", "70.00")
|
||||
STILL = (*STRAIGHT, *CURVES)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory):
|
||||
work = tmp_path_factory.mktemp("b272swap")
|
||||
build = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../resources/tester/helper_272_swap_canon_entry.ts",
|
||||
"--outDir",
|
||||
str(work),
|
||||
"--logLevel",
|
||||
"error",
|
||||
],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
)
|
||||
assert build.returncode == 0, build.stderr
|
||||
bundle = work / "bundle.mjs"
|
||||
shutil.copy(work / "helper_272_swap_canon_entry.js", bundle)
|
||||
proc = subprocess.run( # noqa: S603
|
||||
["node", str(HERE / "helper_272_swap_canon.mjs"), str(bundle)],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_관만_옮김_정본_기하_표본_그대로(out):
|
||||
keep = out["pure"]["keep"]
|
||||
for at in STILL:
|
||||
assert keep[at]["same"], at # 곡선 축 차 3.3e-4 여도 서버 표본 · 기하
|
||||
assert keep[at]["mark"] == f"canon@{float(at):g}"
|
||||
assert keep["55.00"]["browser"] and not keep["55.00"]["same"] # 옮긴 관 = 새 자리 표본
|
||||
assert out["pure"]["keepMoved"] is False # 「노선 밖 고침」 셈 그대로(0.01 m)
|
||||
assert out["pure"]["defaultIsKeep"] # 기본 = 노선 초안 없음
|
||||
|
||||
|
||||
def test_되돌림_정본과_바이트_같음(out):
|
||||
back = out["pure"]["back"]
|
||||
assert all(back[at]["same"] for at in STILL)
|
||||
assert out["pure"]["backBytes"] # 관 측점 밖 횡단 = 정본 바이트
|
||||
assert back["45.00"]["browser"] # 관 측점 = 브라우저 표본(정본은 저장 뒤 서버 값)
|
||||
|
||||
|
||||
def test_격자에_구조물_놓고_뺌_같은_자리_정본(out):
|
||||
g = out["gridStructure"]
|
||||
assert g["placed"]["same"] and not g["placed"]["browser"] # 열쇠 바뀜(구조물) · 같은 자리 = 정본
|
||||
assert g["removed"]["same"] and not g["removed"]["browser"] # 다시 격자 = 정본 표본 · 기하
|
||||
assert g["removedBytes"]
|
||||
|
||||
|
||||
def test_노선_초안_차례는_옛_규칙(out):
|
||||
draft = out["pure"]["draft"]
|
||||
for at in CURVES: # 축 차 > 1e-4 = 새 표본
|
||||
assert draft[at]["browser"] and not draft[at]["same"], at
|
||||
for at in STRAIGHT: # 축 차 1e-5 · 자리 같음 = 앞 표본(기하는 새 틀)
|
||||
assert not draft[at]["browser"] and not draft[at]["same"], at
|
||||
|
||||
|
||||
def test_가게_길_초안_없음(out):
|
||||
s = out["storeNoDraft"]
|
||||
assert s["moved"] and s["back"]
|
||||
assert all(s["moved"][at]["same"] for at in STILL)
|
||||
assert all(s["back"][at]["same"] for at in STILL) and s["backBytes"]
|
||||
|
||||
|
||||
def test_가게_길_초안_있음_버린_직후(out):
|
||||
s = out["storeDraft"]
|
||||
assert s["routeDraftSet"]
|
||||
for at in CURVES: # 초안이 옮긴 곡선 = 새 표본
|
||||
assert s["draft"][at]["browser"], at
|
||||
for at in STRAIGHT: # 초안 차례 = 옛 규칙(기하 새 틀 · 표본 앞 값)
|
||||
assert not s["draft"][at]["same"] and not s["draft"][at]["browser"], at
|
||||
dropped = s["dropped"]
|
||||
for at in CURVES: # 버린 뒤 = 저장 노선 자리 표본(초안 3 cm 자리 표본이 안 남음)
|
||||
assert dropped[at]["browser"], at
|
||||
for at in STRAIGHT: # 초안이 안 옮긴 측점 = 앞 횡단 표본(서버) 그대로
|
||||
assert not dropped[at]["browser"], at
|
||||
Reference in New Issue
Block a user