Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1
This commit is contained in:
+27
-54
@@ -32,8 +32,6 @@ export interface StateEntry {
|
||||
scope: StateScope;
|
||||
/** 형식이 바뀌면 올린다 — 옛 값을 읽지 않고 기본값으로 시작한다. */
|
||||
version?: number;
|
||||
/** 옛 키(있으면 한 번 읽어 옮기고 지운다). `project`·`route` 범위는 함수로 받는다. */
|
||||
legacy?: (projectId?: string, routeId?: number | string) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,14 +46,13 @@ export const STATE_REGISTRY = {
|
||||
"profile-collapsed": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
legacy: () => "b05-route-profile-collapsed",
|
||||
},
|
||||
"profile-height": { bucket: "pref", scope: "global", legacy: () => "b05-route-profile-height" },
|
||||
"profile-height": { bucket: "pref", scope: "global" },
|
||||
/** B06 위 평면 겹침 패널 높이(436) */
|
||||
"plan-peek-height": { bucket: "pref", scope: "global" },
|
||||
/** 배수유역 판 접힘 — 기억 없음 = 닫힘(392-1 · 옛 "false" 기억 버림). */
|
||||
"drainage-collapsed": { bucket: "pref", scope: "global", version: 2 },
|
||||
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
|
||||
"drainage-width": { bucket: "pref", scope: "global" },
|
||||
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
@@ -77,26 +74,22 @@ export const STATE_REGISTRY = {
|
||||
"masshaul-open": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
legacy: () => "b05-route-profile-masshaul-open",
|
||||
},
|
||||
"masshaul-height": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
legacy: () => "b05-route-profile-masshaul-height",
|
||||
},
|
||||
/** 유토곡선 범례 — B05·B06 이 같은 값을 본다(예전에도 키를 공유했다). */
|
||||
"masshaul-visible": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
version: 6,
|
||||
legacy: () => "b06:masshaul-visible-v4",
|
||||
},
|
||||
"table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" },
|
||||
"table-height": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:height" },
|
||||
"table-open": { bucket: "pref", scope: "global" },
|
||||
"table-height": { bucket: "pref", scope: "global" },
|
||||
"section-panel-collapsed": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
legacy: () => "b06:profile-panel-collapsed",
|
||||
},
|
||||
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
|
||||
"cross-columns": { bucket: "pref", scope: "global" },
|
||||
@@ -109,7 +102,6 @@ export const STATE_REGISTRY = {
|
||||
"section-panel-height": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
legacy: () => "b06:profile-panel-height",
|
||||
},
|
||||
|
||||
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
|
||||
@@ -120,21 +112,20 @@ export const STATE_REGISTRY = {
|
||||
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
|
||||
"detail-table": { bucket: "draft", scope: "route" },
|
||||
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
|
||||
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
|
||||
structures: { bucket: "draft", scope: "project" },
|
||||
/** 아직 정본에 안 넣은 추가 측점(정수 m 목록 · 368-2) — 정본 `plus_stations.json` · [저장]이 내보냄. */
|
||||
"plus-stations": { bucket: "draft", scope: "project" },
|
||||
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
|
||||
uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` },
|
||||
uphill: { bucket: "draft", scope: "project" },
|
||||
/** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림.
|
||||
* 예전에는 패널 메모리에만 있어 B06 으로 넘어가면 편집이 사라졌다(대응표 조사).
|
||||
* 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정] 한 본문(`pipesPart`)이
|
||||
* 내보낸 뒤 비운다. */
|
||||
pipes: { bucket: "draft", scope: "project", legacy: (p) => `b05:pipes:${p}` },
|
||||
pipes: { bucket: "draft", scope: "project" },
|
||||
/** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */
|
||||
"structure-pick": {
|
||||
bucket: "draft",
|
||||
scope: "project",
|
||||
legacy: (p) => `aislo:structure-pick:${p}`,
|
||||
},
|
||||
/** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */
|
||||
"pipe-ids": { bucket: "draft", scope: "project" },
|
||||
@@ -147,9 +138,9 @@ export const STATE_REGISTRY = {
|
||||
/** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */
|
||||
kindreset: { bucket: "draft", scope: "project" },
|
||||
/** 배수관·암거 조정창에서 예약한 옵션 값. */
|
||||
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
|
||||
culvertopt: { bucket: "draft", scope: "route" },
|
||||
/** 암 경계선 오프셋(측점별). */
|
||||
rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` },
|
||||
rockb: { bucket: "draft", scope: "route" },
|
||||
/** 측점별 암 절토 경사비(1:n 의 n) — 카드에서 넣은 사용자 값(2026-09-07).
|
||||
* 암 경계선(`rockb`)과 같은 자리·같은 꼴이다. 재계산에 **계산 전에** 실어 보내야
|
||||
* 설계선이 새 경사로 그려진다(값만 베껴 붙이면 그림과 숫자가 어긋난다). */
|
||||
@@ -164,24 +155,23 @@ export const STATE_REGISTRY = {
|
||||
* (`B06_Section_Api_Fetch` seedStandardCross) — 같은 응답의 `standard_cross_section` 은
|
||||
* config 기본값. 고침으로 안 본다 — [폐기]에도 안 지움(`design_leave` NOT_EDITS).
|
||||
* [초기화] · 추천 노선 [적용]에서 버린다. */
|
||||
"std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` },
|
||||
"std-cross": { bucket: "draft", scope: "project" },
|
||||
/** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */
|
||||
"cross-display": {
|
||||
bucket: "draft",
|
||||
scope: "route",
|
||||
legacy: (p, r) => `b06:cross-display:${p}:${r}`,
|
||||
},
|
||||
/* 측점 조정창이 쌓는 4축·단수·연동 값 — 전부 노선 단위 초안이고 [저장]에서 함께 나간다.
|
||||
옛 키는 `b06:{이름}:{프로젝트}:{노선}` 한 규칙이었다. */
|
||||
crossw: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:crossw:${p}:${r}` },
|
||||
revetx: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetx:${p}:${r}` },
|
||||
revetlink: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetlink:${p}:${r}` },
|
||||
basinadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:basinadjust:${p}:${r}` },
|
||||
inletstruct: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:inletstruct:${p}:${r}` },
|
||||
extrawall: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extrawall:${p}:${r}` },
|
||||
fordadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:fordadjust:${p}:${r}` },
|
||||
boxadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:boxadjust:${p}:${r}` },
|
||||
extraspan: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extraspan:${p}:${r}` },
|
||||
crossw: { bucket: "draft", scope: "route" },
|
||||
revetx: { bucket: "draft", scope: "route" },
|
||||
revetlink: { bucket: "draft", scope: "route" },
|
||||
basinadjust: { bucket: "draft", scope: "route" },
|
||||
inletstruct: { bucket: "draft", scope: "route" },
|
||||
extrawall: { bucket: "draft", scope: "route" },
|
||||
fordadjust: { bucket: "draft", scope: "route" },
|
||||
boxadjust: { bucket: "draft", scope: "route" },
|
||||
extraspan: { bucket: "draft", scope: "route" },
|
||||
/** 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택 — 2026-09-06.
|
||||
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
|
||||
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
|
||||
@@ -215,17 +205,17 @@ export const STATE_REGISTRY = {
|
||||
/** D04 연도별 지정 업체 목록(531-3) — [저장] 때 PUT plan/assignees. */
|
||||
"d04-assignees": { bucket: "draft", scope: "project" },
|
||||
/* D02 · E 페이지 캐시(401) — 진입 · 떠남 비우기가 같은 표를 봄 · 옛 키는 한 번 옮김. */
|
||||
"d02-points": { bucket: "draft", scope: "project", legacy: (p) => `d02_points_${p}` },
|
||||
"e02-ground": { bucket: "draft", scope: "project", legacy: (p) => `e02_ground_cache:${p}` },
|
||||
"e03-prob": { bucket: "draft", scope: "project", legacy: (p) => `e03_prob_cache:${p}` },
|
||||
"e04-settings": { bucket: "draft", scope: "project", legacy: (p) => `e04_settings:${p}` },
|
||||
"e05-report": { bucket: "draft", scope: "project", legacy: (p) => `e05_report_cache:${p}` },
|
||||
"e06-rock": { bucket: "draft", scope: "project", legacy: (p) => `e06_rock_cache:${p}` },
|
||||
"e07-settings": { bucket: "draft", scope: "project", legacy: (p) => `e07_settings:${p}` },
|
||||
"d02-points": { bucket: "draft", scope: "project" },
|
||||
"e02-ground": { bucket: "draft", scope: "project" },
|
||||
"e03-prob": { bucket: "draft", scope: "project" },
|
||||
"e04-settings": { bucket: "draft", scope: "project" },
|
||||
"e05-report": { bucket: "draft", scope: "project" },
|
||||
"e06-rock": { bucket: "draft", scope: "project" },
|
||||
"e07-settings": { bucket: "draft", scope: "project" },
|
||||
|
||||
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
|
||||
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
|
||||
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
|
||||
latest: { bucket: "result", scope: "project" },
|
||||
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
|
||||
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
|
||||
* 노선이 바뀌면 `latest` 와 함께 버린다. */
|
||||
@@ -236,13 +226,11 @@ export const STATE_REGISTRY = {
|
||||
"std-cross-default": {
|
||||
bucket: "result",
|
||||
scope: "project",
|
||||
legacy: (p) => `b06:std-cross-default:${p}`,
|
||||
},
|
||||
/** 서버가 준 암 경계선 기본 오프셋 — 위와 같은 성격. */
|
||||
"rock-boundary-default": {
|
||||
bucket: "result",
|
||||
scope: "project",
|
||||
legacy: (p) => `b06:rock-boundary-default:${p}`,
|
||||
},
|
||||
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
|
||||
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
|
||||
@@ -487,20 +475,6 @@ function setOrMakeRoom(store: Storage, key: string, value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */
|
||||
function migrate(
|
||||
name: StateName,
|
||||
key: string,
|
||||
projectId?: string | null,
|
||||
routeId?: number | string | null,
|
||||
): void {
|
||||
const legacy = entryOf(name)?.legacy?.(projectId ?? undefined, routeId ?? undefined);
|
||||
if (!legacy || legacy === key) return;
|
||||
const old = readRaw(legacy);
|
||||
if (old !== null && readRaw(key) === null) writeRaw(key, old);
|
||||
if (old !== null) writeRaw(legacy, null);
|
||||
}
|
||||
|
||||
/** 문자열 그대로 읽는다(숫자·참거짓처럼 JSON 이 아닌 값). */
|
||||
export function readStateRaw(
|
||||
name: StateName,
|
||||
@@ -509,7 +483,6 @@ export function readStateRaw(
|
||||
): string | null {
|
||||
const key = stateKey(name, projectId, routeId);
|
||||
if (!key) return null;
|
||||
migrate(name, key, projectId, routeId);
|
||||
return readRaw(key);
|
||||
}
|
||||
|
||||
|
||||
@@ -65,10 +65,7 @@ export function reloadIfOtherProject(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const MARK_P = "\u0001";
|
||||
const MARK_R = "\u0002";
|
||||
|
||||
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */
|
||||
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`). 초안 여부를 함께. */
|
||||
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
|
||||
const out: Array<{ prefix: string; draft: boolean }> = [];
|
||||
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
|
||||
@@ -76,9 +73,6 @@ function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
|
||||
const draft = entry.bucket === "draft";
|
||||
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
|
||||
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
|
||||
const legacy = entry.legacy?.(MARK_P, MARK_R);
|
||||
const at = legacy?.indexOf(MARK_P) ?? -1;
|
||||
if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -173,14 +173,10 @@ type MemoryKey = keyof typeof MEMORY_SLOTS;
|
||||
/** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */
|
||||
export const PERSIST_DELAY_MS = 1000;
|
||||
const memory = new Map<string, unknown>();
|
||||
const persistTimers = new Map<
|
||||
string,
|
||||
{ timer: ReturnType<typeof setTimeout>; write: () => void }
|
||||
>();
|
||||
const persistTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
function cancelPersist(storageKey: string): void {
|
||||
const pending = persistTimers.get(storageKey);
|
||||
if (pending) clearTimeout(pending.timer);
|
||||
clearTimeout(persistTimers.get(storageKey));
|
||||
persistTimers.delete(storageKey);
|
||||
}
|
||||
|
||||
@@ -189,14 +185,6 @@ onStateRemoved((storageKey) => {
|
||||
cancelPersist(storageKey);
|
||||
});
|
||||
|
||||
/** 미뤄 둔 세션 쓰기를 지금 — [저장] 앞 · 시험. */
|
||||
export function flushDesignStore(): void {
|
||||
const writes = [...persistTimers.values()];
|
||||
persistTimers.forEach((pending) => clearTimeout(pending.timer));
|
||||
persistTimers.clear();
|
||||
writes.forEach((pending) => pending.write());
|
||||
}
|
||||
|
||||
function memorySlot<T>(key: MemoryKey, projectId: string | null): Slot<T> {
|
||||
const name = MEMORY_SLOTS[key];
|
||||
const storageKey = name ? stateKey(name, projectId) : projectId && `memory:${key}:${projectId}`;
|
||||
@@ -223,7 +211,7 @@ function memorySlot<T>(key: MemoryKey, projectId: string | null): Slot<T> {
|
||||
persistTimers.delete(storageKey);
|
||||
writeState(name, memory.get(storageKey) ?? null, projectId);
|
||||
};
|
||||
persistTimers.set(storageKey, { timer: setTimeout(write, PERSIST_DELAY_MS), write });
|
||||
persistTimers.set(storageKey, setTimeout(write, PERSIST_DELAY_MS));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,32 +1,20 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_IrregularStations.ts
|
||||
* 비정규 측점(구조물 측점) 입력·목록·편집 사이드바 섹션.
|
||||
* 비정규 측점(구조물 측점) 꼴 · 배수관 판정 · 표시 라벨 — 종단 그래프 · 표 · 3D 가 같이 씀.
|
||||
*
|
||||
* 사용자가 규칙 격자(측점 간격) 밖의 임의 위치(측점번호 X + 잔여거리 XX)에 구조물 측점을
|
||||
* 추가한다. 지금은 구조물을 자유 텍스트로 받고, 값은 클라이언트에만 보관한다(백엔드 미영속).
|
||||
* 추가/수정/삭제/리셋이 일어날 때마다 `onChange`로 전체 목록을 알려 Page가 그래프·테이블·3D에
|
||||
* 반영한다. 목록에서 항목을 고르면 `onSelect`로 chainage를 알려 하이라이트에 쓴다.
|
||||
*
|
||||
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m). 측점간격은 `getInterval()`로 실시간 조회한다.
|
||||
* chainage_m = 측점번호 × 측점간격(m) + 잔여거리(m).
|
||||
* ========================================================================== */
|
||||
|
||||
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
|
||||
ESCAPE_ROUTE_WIDTHS_M,
|
||||
PIPE_DEFAULT_DIAMETER_MM,
|
||||
PIPE_DEFAULT_TYPE,
|
||||
PIPE_DIAMETERS_MM,
|
||||
PIPE_TYPES,
|
||||
STRUCTURE_ETC_DEFAULT_NAME,
|
||||
STRUCTURE_TYPES,
|
||||
pickPipeDiameter,
|
||||
type StructureType,
|
||||
} from "@config/config_frontend";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
|
||||
// 그래프 측점 id · 측점 표기 = logic(511-B B-3) — 부르는 쪽은 그대로 여기서 받음
|
||||
import { irregularLabel } from "./logic/B05_Profile_Profile_Planted";
|
||||
export { irregularLabel, irregularStationId } from "./logic/B05_Profile_Profile_Planted";
|
||||
|
||||
export interface IrregularStation {
|
||||
@@ -98,473 +86,3 @@ export function structureLabel(entry: {
|
||||
return entry.structure ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
export interface IrregularStationsSection {
|
||||
root: HTMLElement;
|
||||
getStations: () => IrregularStation[];
|
||||
/** chainage로 목록 항목을 골라 폼에 로드한다(그래프·3D에서 선택 시). null이면 선택 해제. */
|
||||
selectByChainage: (chainageM: number | null) => void;
|
||||
/** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */
|
||||
setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void;
|
||||
/** 배수유역도의 관 목록을 "배관" 구조물로 갈아 끼운다. 사용자가 손으로 넣은 항목은 건드리지 않는다.
|
||||
* effective_diameter_mm(배수 유효직경)를 주면 그 이상 첫 규격을 자동 지정한다(기본 D800). */
|
||||
setPipeStations: (
|
||||
pipes: ReadonlyArray<number | { chainage_m: number; effective_diameter_mm?: number | null }>,
|
||||
) => void;
|
||||
/** 우클릭 메뉴에서 배관 외 구조물을 기본 옵션으로 추가한다(종단·배수유역도 공용). */
|
||||
addStructure: (chainageM: number, structureType: "기성막이" | "대피로" | "기타") => void;
|
||||
/** 누가거리로 항목을 찾아 지운다(종단 테이블 우클릭). 지웠으면 그 항목을 돌려준다. */
|
||||
removeByChainage: (chainageM: number) => IrregularStation | null;
|
||||
/** 누가거리로 항목을 옮긴다(종단 테이블 라인 드래그). 옮겼으면 true. */
|
||||
moveByChainage: (fromChainageM: number, toChainageM: number) => boolean;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
interface IrregularStationsCallbacks {
|
||||
/** 측점간격(m). chainage 환산에 쓴다. */
|
||||
getInterval: () => number;
|
||||
/** 목록이 바뀔 때(추가·수정·삭제·리셋) 전체 목록을 넘긴다. */
|
||||
onChange: (stations: IrregularStation[]) => void;
|
||||
/** 목록에서 항목을 선택/해제할 때 해당 측점(또는 null)을 넘긴다. */
|
||||
onSelect: (station: IrregularStation | null) => void;
|
||||
}
|
||||
|
||||
function field(labelText: string, input: HTMLElement): HTMLLabelElement {
|
||||
const wrapper = document.createElement("label");
|
||||
wrapper.className = "b05-route__field";
|
||||
const caption = document.createElement("span");
|
||||
caption.textContent = labelText;
|
||||
wrapper.append(caption, input);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function numberInput(step: string, min: string): HTMLInputElement {
|
||||
const input = document.createElement("input");
|
||||
input.className = "ui-input";
|
||||
input.type = "number";
|
||||
input.step = step;
|
||||
input.min = min;
|
||||
return input;
|
||||
}
|
||||
|
||||
function selectInput(options: ReadonlyArray<string>): HTMLSelectElement {
|
||||
const select = document.createElement("select");
|
||||
select.className = "ui-select";
|
||||
select.replaceChildren(...options.map((value) => new Option(value, value)));
|
||||
return select;
|
||||
}
|
||||
|
||||
export function createIrregularStationsSection(
|
||||
callbacks: IrregularStationsCallbacks,
|
||||
): IrregularStationsSection {
|
||||
const root = document.createElement("section");
|
||||
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
|
||||
root.className = "b05-route__panel-section ui-collapsible ui-sidebar-section";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "ui-collapsible__title";
|
||||
heading.textContent = "구조물 배치";
|
||||
const body = document.createElement("div");
|
||||
body.className = "b05-route__panel-body";
|
||||
root.append(heading, body);
|
||||
|
||||
const stationField = numberInput("1", "0");
|
||||
stationField.placeholder = "측점번호";
|
||||
const remainderField = numberInput("0.1", "0");
|
||||
remainderField.placeholder = "잔여거리";
|
||||
|
||||
// 구조물 종류 + 종류별 하위 옵션 (2026-08-05 사용자 확정. 목록은 config_frontend가 정본).
|
||||
// 첫 항목은 빈칸 — 리셋 시 "선택 안 됨" 상태를 표현한다(빈칸이면 하위 옵션 전체 숨김).
|
||||
const typeSelect = selectInput(["", ...STRUCTURE_TYPES]);
|
||||
const pipeTypeSelect = selectInput(PIPE_TYPES);
|
||||
const diameterSelect = document.createElement("select");
|
||||
diameterSelect.className = "ui-select";
|
||||
const widthSelect = selectInput(ESCAPE_ROUTE_WIDTHS_M.map((width) => width.toFixed(1)));
|
||||
const nameField = document.createElement("input");
|
||||
nameField.className = "ui-input";
|
||||
nameField.type = "text";
|
||||
nameField.placeholder = STRUCTURE_ETC_DEFAULT_NAME;
|
||||
|
||||
const pipeTypeField = field("관종", pipeTypeSelect);
|
||||
const diameterField = field("직경", diameterSelect);
|
||||
const widthField = field("폭 (m)", widthSelect);
|
||||
const nameFieldWrap = field("이름", nameField);
|
||||
const subRow = document.createElement("div");
|
||||
subRow.className = "b05-route__irregular-row";
|
||||
subRow.append(pipeTypeField, diameterField, widthField, nameFieldWrap);
|
||||
|
||||
/** 관종에 맞는 직경 목록으로 갈아 끼운다. 가능하면 현재 선택을 유지한다. */
|
||||
function syncDiameterOptions(keep?: number): void {
|
||||
const sizes = PIPE_DIAMETERS_MM[pipeTypeSelect.value] ?? PIPE_DIAMETERS_MM[PIPE_DEFAULT_TYPE];
|
||||
const wanted = keep ?? (Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM);
|
||||
diameterSelect.replaceChildren(
|
||||
...sizes.map((size) => new Option(`D${size}`, String(size), false, size === wanted)),
|
||||
);
|
||||
if (!sizes.includes(wanted))
|
||||
diameterSelect.value = String(pickPipeDiameter(pipeTypeSelect.value, wanted));
|
||||
}
|
||||
|
||||
/** 종류에 따라 보이는 하위 옵션을 바꾼다 — 배관: 관종+직경 / 대피로: 폭 / 기타: 이름.
|
||||
* 빈칸(선택 안 됨)이면 하위 옵션 전체를 숨긴다(리셋 직후 상태). */
|
||||
function syncSubOptions(): void {
|
||||
const type = typeSelect.value as StructureType | "";
|
||||
pipeTypeField.hidden = diameterField.hidden = type !== "배관";
|
||||
widthField.hidden = type !== "대피로";
|
||||
nameFieldWrap.hidden = type !== "기타";
|
||||
// 기성막이는 하위 옵션 미정(추가반영 예정), 빈칸은 아무것도 안 보인다.
|
||||
subRow.hidden = type === "기성막이" || type === "";
|
||||
}
|
||||
typeSelect.addEventListener("change", () => {
|
||||
syncSubOptions();
|
||||
// 편집 중 구조물 타입을 바꾸면 기존 항목 수정이 아니라 **신규 추가**로 전환한다
|
||||
// (2026-08-05 사용자 지시). 입력값은 그대로 두고 편집 상태만 푼다.
|
||||
if (editingId) {
|
||||
editingId = null;
|
||||
syncButtons();
|
||||
renderList();
|
||||
callbacks.onSelect(null);
|
||||
}
|
||||
});
|
||||
pipeTypeSelect.addEventListener("change", () => syncDiameterOptions());
|
||||
typeSelect.value = ""; // 초기·리셋 상태는 빈칸(2026-08-05 사용자 지시)
|
||||
pipeTypeSelect.value = PIPE_DEFAULT_TYPE;
|
||||
widthSelect.value = ESCAPE_ROUTE_DEFAULT_WIDTH_M.toFixed(1);
|
||||
syncDiameterOptions(PIPE_DEFAULT_DIAMETER_MM);
|
||||
syncSubOptions();
|
||||
|
||||
const stationRow = document.createElement("div");
|
||||
stationRow.className = "b05-route__irregular-row";
|
||||
stationRow.append(field("측점번호", stationField), field("잔여거리 (m)", remainderField));
|
||||
|
||||
const primary = document.createElement("button");
|
||||
primary.type = "button";
|
||||
primary.className = "ui-btn ui-btn--filled b05-route__irregular-btn is-primary";
|
||||
const remove = document.createElement("button");
|
||||
remove.type = "button";
|
||||
remove.className = "ui-btn ui-btn--danger-ghost b05-route__irregular-btn is-danger";
|
||||
remove.textContent = "삭제";
|
||||
const reset = document.createElement("button");
|
||||
reset.type = "button";
|
||||
reset.className = "ui-btn ui-btn--ghost b05-route__irregular-btn";
|
||||
reset.textContent = "리셋";
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "b05-route__irregular-actions";
|
||||
actions.append(primary, remove, reset);
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b05-route__irregular-list";
|
||||
const help = document.createElement("p");
|
||||
help.className = "ui-text ui-text--muted b05-route__note"; // 부품 보조 글(553-2)
|
||||
help.textContent =
|
||||
"구조물을 설치할 위치를 측점번호+잔여거리로 추가합니다. 목록에서 고르면 수정·삭제할 수 있습니다.";
|
||||
|
||||
// 측점번호/잔여거리를 최상단에 둔다(위치가 먼저, 그다음 구조물 종류·하위 옵션).
|
||||
body.append(stationRow, field("구조물", typeSelect), subRow, actions, list, help);
|
||||
|
||||
const stations: IrregularStation[] = [];
|
||||
let editingId: string | null = null;
|
||||
let nextId = 1;
|
||||
|
||||
function chainageOf(station: number, remainder: number): number {
|
||||
const interval = callbacks.getInterval();
|
||||
return station * (interval > 0 ? interval : 20) + remainder;
|
||||
}
|
||||
|
||||
/** 누가거리를 측점번호+잔여거리로 되돌린다(외부 주입·이동 공용). */
|
||||
function splitChainage(chainageM: number): { station: number; remainder: number } {
|
||||
const interval = intervalMax();
|
||||
const station = Math.floor((chainageM + 1e-6) / interval);
|
||||
return {
|
||||
station,
|
||||
remainder: Number((chainageM - station * interval).toFixed(3)),
|
||||
};
|
||||
}
|
||||
|
||||
function syncButtons(): void {
|
||||
primary.textContent = editingId ? "수정" : "추가";
|
||||
remove.disabled = editingId === null;
|
||||
}
|
||||
|
||||
function loadForm(target: IrregularStation | null): void {
|
||||
editingId = target?.id ?? null;
|
||||
stationField.value = target ? String(target.station) : "";
|
||||
remainderField.value = target ? String(target.remainder) : "";
|
||||
// 구버전 항목(structureType 없음)은 "기타"로 열고 이름칸에 기존 텍스트를 옮긴다.
|
||||
// 선택 해제·리셋이면 종류도 빈칸으로 — 하위 옵션이 전부 숨는다(2026-08-05 사용자 지시).
|
||||
const type = target ? (target.structureType ?? "기타") : "";
|
||||
typeSelect.value = type;
|
||||
pipeTypeSelect.value = target?.pipeType ?? PIPE_DEFAULT_TYPE;
|
||||
syncDiameterOptions(target?.diameterMm ?? PIPE_DEFAULT_DIAMETER_MM);
|
||||
widthSelect.value = (target?.escapeWidthM ?? ESCAPE_ROUTE_DEFAULT_WIDTH_M).toFixed(1);
|
||||
nameField.value = target
|
||||
? (target.customName ?? (target.structureType ? "" : target.structure))
|
||||
: "";
|
||||
// 배수유역 자동 배관(정본 투영)은 관종·직경만 바꿀 수 있다 — 타입 자체는 잠근다
|
||||
// (2026-08-05 사용자 지시. 배관을 다른 구조물로 바꾸면 관 지점 정본과 어긋난다).
|
||||
typeSelect.disabled = !!target && isPipeStation(target);
|
||||
syncSubOptions();
|
||||
syncButtons();
|
||||
// 선택 강조(is-selected)가 클릭 즉시 따라오도록 목록을 다시 그린다(토글 해제 포함).
|
||||
renderList();
|
||||
callbacks.onSelect(target ?? null);
|
||||
}
|
||||
|
||||
function renderList(): void {
|
||||
list.replaceChildren();
|
||||
if (!stations.length) {
|
||||
// 부품 빈 상태 작은 꼴(553-2)
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "ui-empty ui-empty--small b05-route__irregular-empty";
|
||||
const text = document.createElement("p");
|
||||
text.className = "ui-empty__text";
|
||||
text.textContent = "추가된 비정규 측점이 없습니다.";
|
||||
empty.append(text);
|
||||
list.append(empty);
|
||||
return;
|
||||
}
|
||||
[...stations]
|
||||
.sort((a, b) => a.chainage_m - b.chainage_m)
|
||||
.forEach((station) => {
|
||||
const item = document.createElement("li");
|
||||
item.className = "b05-route__irregular-item";
|
||||
item.classList.toggle("is-selected", station.id === editingId);
|
||||
const name = document.createElement("strong");
|
||||
name.textContent = irregularLabel(station);
|
||||
const info = document.createElement("span");
|
||||
info.textContent = station.structure || "(구조물 미입력)";
|
||||
item.append(name, info);
|
||||
// 같은 항목 재클릭 = 선택 해제(2026-08-04 사용자 지시) — 그래프·3D 강조도 함께 풀린다.
|
||||
item.addEventListener("click", () => loadForm(station.id === editingId ? null : station));
|
||||
list.append(item);
|
||||
// 목록이 5개를 넘으면 내부 스크롤이 생긴다 — 선택(=수정 중) 항목이 스크롤 밖에
|
||||
// 있으면 안 보이므로, 렌더 직후 그 항목만 보이는 위치로 데려온다.
|
||||
if (station.id === editingId) item.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
|
||||
/** 잔여거리는 측점 간격을 넘을 수 없다(넘으면 다음 측점이 됨). max·검증에 함께 쓴다. */
|
||||
function intervalMax(): number {
|
||||
const interval = callbacks.getInterval();
|
||||
return interval > 0 ? interval : 20;
|
||||
}
|
||||
|
||||
function commit(): void {
|
||||
const station = Number.parseInt(stationField.value, 10);
|
||||
const remainder = Number.parseFloat(remainderField.value || "0");
|
||||
if (!Number.isFinite(station) || station < 0) {
|
||||
stationField.focus();
|
||||
return;
|
||||
}
|
||||
// 잔여거리는 [0, 측점간격] 범위로 클램프한다.
|
||||
const safeRemainder = Number.isFinite(remainder)
|
||||
? Math.min(Math.max(remainder, 0), intervalMax())
|
||||
: 0;
|
||||
// 종류 미선택(빈칸)이면 추가하지 않는다 — 리셋 직후 상태.
|
||||
if (!typeSelect.value) {
|
||||
typeSelect.focus();
|
||||
return;
|
||||
}
|
||||
// 드롭다운 선택값 → 옵션 필드 + 표시 라벨. 종류에 안 쓰는 필드는 넣지 않는다.
|
||||
const structureType = typeSelect.value as StructureType;
|
||||
const options: Partial<IrregularStation> = { structureType };
|
||||
if (structureType === "배관") {
|
||||
options.pipeType = pipeTypeSelect.value;
|
||||
options.diameterMm = Number(diameterSelect.value) || PIPE_DEFAULT_DIAMETER_MM;
|
||||
} else if (structureType === "대피로") {
|
||||
options.escapeWidthM = Number(widthSelect.value) || ESCAPE_ROUTE_DEFAULT_WIDTH_M;
|
||||
} else if (structureType === "기타") {
|
||||
options.customName = nameField.value.trim() || STRUCTURE_ETC_DEFAULT_NAME;
|
||||
}
|
||||
const structure = structureLabel(options);
|
||||
const chainage_m = chainageOf(station, safeRemainder);
|
||||
if (editingId) {
|
||||
// 그 자리에서 변경(Object.assign)하면 Page가 보관한 이전 목록의 객체도 함께 바뀌어
|
||||
// "이동 전 chainage"를 잃는다 → 옛 위치의 계획고·곡선 편집을 정리하지 못한다.
|
||||
// 새 객체로 교체해, Page가 이전 위치를 감지하고 그 편집을 지우게 한다.
|
||||
const index = stations.findIndex((entry) => entry.id === editingId);
|
||||
if (index >= 0) {
|
||||
const previous = stations[index];
|
||||
// 배관 관종·직경을 손으로 바꿨으면 자동 지정이 다시 덮지 않게 표시한다.
|
||||
const userSized =
|
||||
structureType === "배관" &&
|
||||
(previous.userSized ||
|
||||
previous.pipeType !== options.pipeType ||
|
||||
previous.diameterMm !== options.diameterMm);
|
||||
stations[index] = {
|
||||
...previous,
|
||||
id: editingId,
|
||||
station,
|
||||
remainder: safeRemainder,
|
||||
chainage_m,
|
||||
structure,
|
||||
...options,
|
||||
userSized,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
stations.push({
|
||||
id: String(nextId++),
|
||||
station,
|
||||
remainder: safeRemainder,
|
||||
chainage_m,
|
||||
structure,
|
||||
...options,
|
||||
origin: "user",
|
||||
});
|
||||
}
|
||||
loadForm(null);
|
||||
renderList();
|
||||
callbacks.onChange([...stations]);
|
||||
}
|
||||
|
||||
// 측점 간격은 옵션에서 바뀔 수 있으므로, 잔여거리의 max를 입력 직전에 현재 간격으로 맞춘다.
|
||||
const syncRemainderMax = (): void => {
|
||||
remainderField.max = String(intervalMax());
|
||||
};
|
||||
remainderField.addEventListener("focus", syncRemainderMax);
|
||||
remainderField.addEventListener("input", syncRemainderMax);
|
||||
syncRemainderMax();
|
||||
|
||||
primary.addEventListener("click", commit);
|
||||
remove.addEventListener("click", () => {
|
||||
if (!editingId) return;
|
||||
const index = stations.findIndex((entry) => entry.id === editingId);
|
||||
if (index >= 0) stations.splice(index, 1);
|
||||
loadForm(null);
|
||||
renderList();
|
||||
callbacks.onChange([...stations]);
|
||||
});
|
||||
reset.addEventListener("click", () => loadForm(null));
|
||||
|
||||
syncButtons();
|
||||
renderList();
|
||||
|
||||
return {
|
||||
root,
|
||||
getStations: () => [...stations],
|
||||
selectByChainage(chainageM) {
|
||||
if (chainageM === null) {
|
||||
loadForm(null);
|
||||
return;
|
||||
}
|
||||
const target = stations.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6);
|
||||
loadForm(target ?? null);
|
||||
},
|
||||
setStations(seed) {
|
||||
stations.length = 0;
|
||||
seed.forEach((entry) => {
|
||||
const { station, remainder } = splitChainage(entry.chainage_m);
|
||||
stations.push({
|
||||
id: String(nextId++),
|
||||
station,
|
||||
remainder,
|
||||
chainage_m: entry.chainage_m,
|
||||
structure: entry.structure,
|
||||
origin: "user",
|
||||
});
|
||||
});
|
||||
loadForm(null);
|
||||
renderList();
|
||||
callbacks.onChange([...stations]);
|
||||
},
|
||||
setPipeStations(pipes) {
|
||||
// 배관 항목은 통째로 갈아 끼운다 — 정본은 배수유역도의 관 목록이다.
|
||||
// 단, 사용자가 관종·직경을 손으로 바꾼 항목(userSized)은 같은 자리에 다시 올 때 유지한다.
|
||||
const previousPipes = stations.filter(isPipeStation);
|
||||
const incomingChainages = pipes.map((pipe) =>
|
||||
typeof pipe === "number" ? pipe : pipe.chainage_m,
|
||||
);
|
||||
for (let index = stations.length - 1; index >= 0; index -= 1) {
|
||||
const entry = stations[index];
|
||||
// 경로확정 정본(종단 병합분)에서 복원된 항목은 구조물 종류(structureType)가 없다.
|
||||
// 그 중 정본 관과 같은 자리(±0.5m)인 것은 관의 **복원 유령**이다 — 지금 투영되는
|
||||
// 정본 관이 진짜이므로 걷어낸다. 같은 배관이 목록에 2개씩 뜨던 원인(2026-08-06
|
||||
// 사용자 보고). 종류를 갖춘 사용자 추가 항목은 자리가 겹쳐도 건드리지 않는다.
|
||||
const restoredGhost =
|
||||
entry.structureType === undefined &&
|
||||
incomingChainages.some(
|
||||
(chainage) => Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
|
||||
);
|
||||
if (isPipeStation(entry) || restoredGhost) stations.splice(index, 1);
|
||||
}
|
||||
pipes.forEach((pipe) => {
|
||||
const chainage = typeof pipe === "number" ? pipe : pipe.chainage_m;
|
||||
const effective = typeof pipe === "number" ? null : (pipe.effective_diameter_mm ?? null);
|
||||
const { station, remainder } = splitChainage(chainage);
|
||||
const kept = previousPipes.find(
|
||||
(entry) => entry.userSized && Math.abs(entry.chainage_m - chainage) < PIPE_MATCH_M,
|
||||
);
|
||||
const pipeType = kept?.pipeType ?? PIPE_DEFAULT_TYPE;
|
||||
const diameterMm = kept?.diameterMm ?? pickPipeDiameter(pipeType, effective);
|
||||
const entry: IrregularStation = {
|
||||
id: String(nextId++),
|
||||
station,
|
||||
remainder,
|
||||
chainage_m: chainage,
|
||||
structure: "",
|
||||
structureType: PIPE_STRUCTURE_NAME,
|
||||
pipeType,
|
||||
diameterMm,
|
||||
userSized: kept?.userSized ?? false,
|
||||
origin: "pipe",
|
||||
};
|
||||
entry.structure = structureLabel(entry);
|
||||
stations.push(entry);
|
||||
});
|
||||
if (editingId && !stations.some((entry) => entry.id === editingId)) loadForm(null);
|
||||
renderList();
|
||||
callbacks.onChange([...stations]);
|
||||
},
|
||||
addStructure(chainageM, structureType) {
|
||||
const { station, remainder } = splitChainage(chainageM);
|
||||
const entry: IrregularStation = {
|
||||
id: String(nextId++),
|
||||
station,
|
||||
remainder,
|
||||
chainage_m: chainageM,
|
||||
structure: "",
|
||||
structureType,
|
||||
origin: "user",
|
||||
};
|
||||
if (structureType === "대피로") entry.escapeWidthM = ESCAPE_ROUTE_DEFAULT_WIDTH_M;
|
||||
if (structureType === "기타") entry.customName = STRUCTURE_ETC_DEFAULT_NAME;
|
||||
entry.structure = structureLabel(entry);
|
||||
stations.push(entry);
|
||||
renderList();
|
||||
callbacks.onChange([...stations]);
|
||||
},
|
||||
removeByChainage(chainageM) {
|
||||
const index = stations.findIndex(
|
||||
(entry) => Math.abs(entry.chainage_m - chainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (index < 0) return null;
|
||||
const [removed] = stations.splice(index, 1);
|
||||
if (editingId === removed.id) loadForm(null);
|
||||
renderList();
|
||||
callbacks.onChange([...stations]);
|
||||
return removed;
|
||||
},
|
||||
moveByChainage(fromChainageM, toChainageM) {
|
||||
const index = stations.findIndex(
|
||||
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
|
||||
);
|
||||
if (index < 0) return false;
|
||||
const { station, remainder } = splitChainage(toChainageM);
|
||||
// 그 자리에서 고치지 않고 새 객체로 교체한다 — Page가 옛 위치의 계획고 편집을 정리해야 한다.
|
||||
stations[index] = {
|
||||
...stations[index],
|
||||
station,
|
||||
remainder,
|
||||
chainage_m: toChainageM,
|
||||
};
|
||||
renderList();
|
||||
callbacks.onChange([...stations]);
|
||||
return true;
|
||||
},
|
||||
clear() {
|
||||
stations.length = 0;
|
||||
loadForm(null);
|
||||
renderList();
|
||||
callbacks.onChange([]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -608,20 +608,6 @@ export function slopePieces(
|
||||
return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks };
|
||||
}
|
||||
|
||||
/** 측점들 → 평면 발자국(도로 끝이 없는 측점은 뺌). */
|
||||
export function planFootprint(
|
||||
route: Array<[number, number, number]>,
|
||||
stations: FootprintStation[],
|
||||
startM: number,
|
||||
endM: number,
|
||||
stepM: number = TICK_STEP_M,
|
||||
): SlopePieces {
|
||||
const records = stations
|
||||
.map(footprintRecord)
|
||||
.filter((record): record is FootprintRecord => record !== null);
|
||||
return slopePieces(route, records, startM, endM, stepM);
|
||||
}
|
||||
|
||||
/** 계약 `PlanFootprint` + 비탈 끝 점마다 깎기 여부 · 눈금 · 측점 줄(다음 계산이 범위 밖을 재사용). */
|
||||
export interface FootprintLines extends PlanFootprint {
|
||||
slopeLeftCut: boolean[];
|
||||
|
||||
@@ -1,87 +1,15 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_UI_Profile_Alignment_Edit.ts
|
||||
* 종단 계획선 편집 도구 — 구간 평행이동(쉬프트) · 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts`
|
||||
* 종단 계획선 편집 도구 — 곡선 R 입력. `B05_Profile_UI_Profile_Alignment.ts`
|
||||
* 에서 나눔(700줄 · 511-B B-3) · 선형 짓기(`buildAlignment`)는 그 파일.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
buildAlignment,
|
||||
chainageKey,
|
||||
interpolate,
|
||||
planElevationAt,
|
||||
type AlignmentBase,
|
||||
type AlignmentCurve,
|
||||
type AlignmentEdits,
|
||||
type AlignmentSegment,
|
||||
type ProfileAlignment,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
const FEEDBACK_PASSES = 4;
|
||||
const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
|
||||
/**
|
||||
* 구간 이동에서 실제로 움직일 두 지점(2026-08-23 사용자 개념 확정).
|
||||
*
|
||||
* 구간 끝이 사용자 변화점(틸팅한 점)이면 그 점이 직접 움직인다 — 반복 쉬프트.
|
||||
* 고정점(BP·EP·자동 변화점 = 횡단배수 앵커)이면 **절대 움직이지 않고**, 구간 안쪽
|
||||
* 틸트 안 된 정규 측점 중 외곽(첫/끝)을 힌지로 승격시킨다 — 힌지에 라운드가 생기고
|
||||
* 고정점과는 직선으로 이어진다. 힌지 둘을 만들 수 없으면 null(쉬프트 불가 구간:
|
||||
* 안쪽 미틸트 측점 2개 미만).
|
||||
*/
|
||||
export function shiftMovingPoints(
|
||||
alignment: ProfileAlignment,
|
||||
segment: AlignmentSegment,
|
||||
): [number, number] | null {
|
||||
const offsets = alignment.edits.station_offsets;
|
||||
const movable = (chainage: number): boolean => offsets[chainageKey(chainage)] !== undefined;
|
||||
const interior = alignment.stations
|
||||
.map((station) => station.chainage_m)
|
||||
.filter((chainage) => chainage > segment.from_m + 1e-6 && chainage < segment.to_m - 1e-6);
|
||||
const left = movable(segment.from_m) ? segment.from_m : interior[0];
|
||||
const right = movable(segment.to_m) ? segment.to_m : interior[interior.length - 1];
|
||||
if (left === undefined || right === undefined) return null;
|
||||
if (right - left < 1e-6) return null;
|
||||
return [left, right];
|
||||
}
|
||||
|
||||
/**
|
||||
* 구간 평행이동 — 움직일 두 지점(끝 변화점 또는 승격 힌지)에 같은 델타를 준다.
|
||||
* 두 지점 사이 직선의 기울기는 보존되고, 고정점과의 연결 직선·라운드만 다시 잡힌다.
|
||||
* 라운드(중앙종거) 탓에 1클릭이 정확히 delta가 되도록 화면 계획고 기준으로 되먹인다.
|
||||
*/
|
||||
export function shiftSegment(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
segment: AlignmentSegment,
|
||||
delta: number,
|
||||
): AlignmentEdits {
|
||||
const current = buildAlignment(base, edits);
|
||||
const moving = shiftMovingPoints(current, segment);
|
||||
if (!moving) return edits;
|
||||
const baseS = base.basePvi.map((node) => node.chainage_m);
|
||||
const baseZ = base.basePvi.map((node) => node.elevation_m);
|
||||
const targets = moving.map((chainage) => planElevationAt(current, chainage) + delta);
|
||||
const offsets = { ...edits.station_offsets };
|
||||
moving.forEach((chainage) => {
|
||||
const key = chainageKey(chainage);
|
||||
const seed =
|
||||
offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage);
|
||||
offsets[key] = Number((seed + delta).toFixed(6));
|
||||
});
|
||||
for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) {
|
||||
const trial = buildAlignment(base, { ...edits, station_offsets: offsets });
|
||||
let worst = 0;
|
||||
moving.forEach((chainage, index) => {
|
||||
const key = chainageKey(chainage);
|
||||
const error = targets[index] - planElevationAt(trial, chainage);
|
||||
worst = Math.max(worst, Math.abs(error));
|
||||
offsets[key] = Number((offsets[key] + error).toFixed(6));
|
||||
});
|
||||
if (worst < FEEDBACK_TOLERANCE_M) break;
|
||||
}
|
||||
return { ...edits, station_offsets: offsets };
|
||||
}
|
||||
|
||||
/**
|
||||
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
|
||||
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
|
||||
|
||||
@@ -33,24 +33,3 @@ export function chainageToStation(
|
||||
}
|
||||
return { station, remainder };
|
||||
}
|
||||
|
||||
/** 구조물 측점과 겹쳤다고 볼 거리(m) — 이보다 가까우면 규칙 측점을 지운다. */
|
||||
export const STATION_MERGE_TOLERANCE_M = 0.1;
|
||||
|
||||
/**
|
||||
* 구조물 측점과 **겹치는 규칙 측점을 지운다**(2026-09-06).
|
||||
*
|
||||
* 구조물은 보조말뚝을 박아 그 자리가 정본 측점이 된다. 규칙 격자(20m)와 0.1m 안에서
|
||||
* 만나면 세로선·라벨이 두 겹으로 겹쳐 읽히지 않으므로 구조물 쪽만 남긴다.
|
||||
*/
|
||||
export function dropStationsNear<T extends { chainage_m: number }>(
|
||||
stations: readonly T[],
|
||||
anchors: ReadonlyArray<{ chainage_m: number }>,
|
||||
toleranceM: number = STATION_MERGE_TOLERANCE_M,
|
||||
): T[] {
|
||||
if (anchors.length === 0) return [...stations];
|
||||
return stations.filter(
|
||||
(station) =>
|
||||
!anchors.some((anchor) => Math.abs(anchor.chainage_m - station.chainage_m) <= toleranceM),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ def extend_unclosed_sections(
|
||||
ford_surface_drops,
|
||||
stored_berm,
|
||||
stored_cut_slope,
|
||||
stored_section_mode,
|
||||
)
|
||||
from common_util.common_util_route_profile import design_elevation_from_longitudinal
|
||||
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
@@ -146,7 +147,7 @@ def extend_unclosed_sections(
|
||||
elevation = design_elevation_from_longitudinal(longitudinal, chainage)
|
||||
options = dict(
|
||||
ground_type=str(design.get("ground_type") or "ripping_rock"),
|
||||
section_mode=str(design.get("section_mode") or "left_cut"),
|
||||
section_mode=stored_section_mode(design, section),
|
||||
ditch_side=design.get("ditch_side"),
|
||||
ditch_type=str(design.get("ditch_type") or "standard"),
|
||||
paved=bool(design.get("paved", False)),
|
||||
|
||||
@@ -179,7 +179,7 @@ def enforce_pavement_ranges(
|
||||
try:
|
||||
options = dict(
|
||||
ground_type=str(design.get("ground_type") or "ripping_rock"),
|
||||
section_mode=str(design.get("section_mode") or "left_cut"),
|
||||
section_mode=stored_section_mode(design, section),
|
||||
ditch_side=design.get("ditch_side"),
|
||||
ditch_type=str(design.get("ditch_type") or "standard"),
|
||||
paved=wanted,
|
||||
@@ -230,7 +230,7 @@ def enforce_design_lines(
|
||||
try:
|
||||
options = dict(
|
||||
ground_type=str(design.get("ground_type") or "ripping_rock"),
|
||||
section_mode=str(design.get("section_mode") or "left_cut"),
|
||||
section_mode=stored_section_mode(design, section),
|
||||
ditch_side=design.get("ditch_side"),
|
||||
ditch_type=str(design.get("ditch_type") or "standard"),
|
||||
paved=bool(design.get("paved", False)),
|
||||
@@ -280,7 +280,7 @@ def enforce_ford_surface_drops(
|
||||
try:
|
||||
options = dict(
|
||||
ground_type=str(design.get("ground_type") or "ripping_rock"),
|
||||
section_mode=str(design.get("section_mode") or "left_cut"),
|
||||
section_mode=stored_section_mode(design, section),
|
||||
ditch_side=design.get("ditch_side"),
|
||||
ditch_type=str(design.get("ditch_type") or "standard"),
|
||||
paved=bool(design.get("paved", False)),
|
||||
@@ -395,6 +395,16 @@ def attach_default_designs(
|
||||
section["design"] = design
|
||||
|
||||
|
||||
def stored_section_mode(stored: dict[str, Any], section: dict[str, Any]) -> str:
|
||||
"""저장분 단면 유형 — 없으면(옛 설계) 상단측 깎기(지형이 정함 · 요구 409) · 상단측 모르면 좌.
|
||||
|
||||
짝: TS `B06_Section_Cross_Save` storedOptions · `B06_Section_Cross_Refresh_Calc` ·
|
||||
`B06_Section_Cross_Stage`(`uphillSide`) · 거울 시험 `test_472_8_section_mode_default.py`.
|
||||
"""
|
||||
side = section.get("uphill_side")
|
||||
return str(stored.get("section_mode") or f"{side if side in ('left', 'right') else 'left'}_cut")
|
||||
|
||||
|
||||
def stored_cut_slope(stored: dict[str, Any]) -> float | None:
|
||||
"""저장분에 남은 측점별 암 절토 경사비 — 없거나 0(표준값으로 되돌림)이면 None.
|
||||
|
||||
|
||||
@@ -310,7 +310,7 @@ def _enforce_on_node(
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M
|
||||
|
||||
keys = ("chainage_m", "design", "samples", "frame", *CURVE_KEYS)
|
||||
keys = ("chainage_m", "design", "samples", "frame", "uphill_side", *CURVE_KEYS)
|
||||
profiles = longitudinal.get("design_profiles") or []
|
||||
results = run_calc(
|
||||
["cross.enforce"],
|
||||
|
||||
@@ -209,9 +209,12 @@ export function refreshDesigns(
|
||||
groundType:
|
||||
choice?.ground_type ??
|
||||
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
|
||||
// 없으면(옛 설계) 상단측 깎기 · 모르면 좌(요구 409 · 서버 `stored_section_mode` 짝)
|
||||
sectionMode:
|
||||
choice?.section_mode ??
|
||||
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
|
||||
(typeof design.section_mode === "string" && design.section_mode
|
||||
? design.section_mode
|
||||
: `${section.uphill_side === "right" ? "right" : "left"}_cut`),
|
||||
ditchSide:
|
||||
choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null),
|
||||
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
|
||||
|
||||
@@ -165,7 +165,10 @@ export function elevationAt(
|
||||
function storedOptions(design: Raw, section: Raw, rockDefaultM: number): Raw {
|
||||
return {
|
||||
ground_type: String(design.ground_type || "ripping_rock"),
|
||||
section_mode: String(design.section_mode || "left_cut"),
|
||||
// 없으면(옛 설계) 상단측 깎기 · 모르면 좌(요구 409 · 파이썬 `stored_section_mode`)
|
||||
section_mode: String(
|
||||
design.section_mode || `${section.uphill_side === "right" ? "right" : "left"}_cut`,
|
||||
),
|
||||
ditch_side: design.ditch_side ?? null,
|
||||
ditch_type: String(design.ditch_type || "standard"),
|
||||
paved: Boolean(design.paved),
|
||||
@@ -190,6 +193,7 @@ export interface SaveSection {
|
||||
plan_radius_m?: number | null;
|
||||
curve_outer_side?: string | null;
|
||||
curve_widening_m?: number | null;
|
||||
uphill_side?: "left" | "right" | null;
|
||||
}
|
||||
|
||||
export interface SaveCrossInput {
|
||||
|
||||
@@ -29,10 +29,11 @@ import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Gro
|
||||
import type { DtmGrid } from "@util/common_util_surface_sampler";
|
||||
import type { CulvertPipePoint } from "@util/common_util_culvert_sets";
|
||||
import { facilityAt, facilityRanges, type FacilityStructure } from "./B06_Section_Cross_Enforce";
|
||||
import { uphillSide } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
|
||||
export { groundSamples };
|
||||
|
||||
/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 좌절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */
|
||||
/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 상단측 절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */
|
||||
export type CrossStationInput = Partial<
|
||||
Omit<CrossDesignOptions, "standard" | "planRadiusM" | "curveOuterSide" | "curveWideningM">
|
||||
>;
|
||||
@@ -53,7 +54,7 @@ function optionsFor(
|
||||
): CrossDesignOptions {
|
||||
return {
|
||||
groundType: "ripping_rock",
|
||||
sectionMode: "left_cut",
|
||||
sectionMode: `${uphillSide(station) ?? "left"}_cut`, // 상단측 깎기 · 모르면 좌(요구 409)
|
||||
ditchType: "standard",
|
||||
...input,
|
||||
standard,
|
||||
|
||||
@@ -52,8 +52,6 @@ export function setGeneralStations(
|
||||
listed = (stations ?? []).map((station) => station.chainage_m);
|
||||
}
|
||||
|
||||
export const generalStations = (): readonly number[] => general;
|
||||
|
||||
let plusSource: () => readonly number[] = () => [];
|
||||
|
||||
/** 추가 측점 읽는 곳을 둔다(368-2) — 부를 때마다 읽음(되돌리기 · 다른 보기가 바꿔도 같은 값). */
|
||||
|
||||
@@ -34,12 +34,6 @@ export function isSummaryItem(value: unknown): value is SummaryItem {
|
||||
);
|
||||
}
|
||||
|
||||
/** 같은 항목인가 — 층 · 주인 · 키가 같으면 같음(이름은 바뀔 수 있어 안 봄). */
|
||||
export function sameSummaryItem(left: SummaryItem | null, right: SummaryItem | null): boolean {
|
||||
if (!left || !right) return left === right;
|
||||
return left.layer === right.layer && left.owner === right.owner && left.key === right.key;
|
||||
}
|
||||
|
||||
/** 서버 · 세션에서 온 값을 참조 또는 null 로 — 모양이 틀리면 null(미지정). */
|
||||
export function readSummaryItem(value: unknown): SummaryItem | null {
|
||||
return isSummaryItem(value) ? value : null;
|
||||
|
||||
@@ -73,7 +73,6 @@ station.setGeneralStations(
|
||||
{ chainage_m: 80.17, kind: "irregular" },
|
||||
]),
|
||||
);
|
||||
out.general = [...station.generalStations()];
|
||||
out.defaultSnap = station.snapStructureStation(80.4);
|
||||
|
||||
// ② 좌측 폼 칸 — 친 값 80.17 → 80 · 안 고친 옛 값은 그대로
|
||||
|
||||
@@ -47,7 +47,6 @@ const C = "cccc3333";
|
||||
const SEED = {
|
||||
[`aislo:draft:structures:${A}`]: '[{"id":1}]',
|
||||
[`aislo:draft:crossw:${A}:12`]: '{"10":1}',
|
||||
[`b06:culvertopt:${A}:12`]: '{"x":1}', // 옛 키(초안)
|
||||
[`aislo:result:latest:${A}`]: '{"r":1}',
|
||||
[`aislo:draft:structures:${B}`]: '[{"id":2}]',
|
||||
[`aislo:result:section-detail:${B}:7`]: '{"d":1}',
|
||||
@@ -55,8 +54,6 @@ const SEED = {
|
||||
[`aislo:draft:pipes:${C}`]: "{}", // 빈 초안 = 고침 없음
|
||||
[`b09:page-cache:${C}`]: "1", // 등록표 밖 페이지 칸
|
||||
"aislo.otp-pending": "x", // 프로젝트와 무관
|
||||
[`d02_points_${A}`]: '{"base":1}', // D02 옛 키(401 · 등록표 옛 키 머리로 잡음)
|
||||
[`e03_prob_cache:${C}`]: "{}", // E 옛 키 · 빈 값 = 고침 없음
|
||||
};
|
||||
const seed = (extra = {}) => {
|
||||
global.window.sessionStorage = fakeStorage();
|
||||
|
||||
@@ -302,8 +302,6 @@ async function run() {
|
||||
await tick(storeMod.PERSIST_DELAY_MS + 50);
|
||||
const afterDelay = JSON.parse(session.getItem(key("design-stations")));
|
||||
store.set("profile", { schema_version: 1 });
|
||||
storeMod.flushDesignStore();
|
||||
const flushed = JSON.parse(session.getItem(key("design-profile")));
|
||||
store.set("generation", 7);
|
||||
const generationSession = [...Array(session.length).keys()]
|
||||
.map((i) => session.key(i))
|
||||
@@ -336,7 +334,6 @@ async function run() {
|
||||
beforeFlush,
|
||||
memoryRead,
|
||||
afterDelay,
|
||||
flushed,
|
||||
generation: store.get("generation"),
|
||||
generationSession,
|
||||
cleared,
|
||||
|
||||
@@ -115,16 +115,9 @@ const keys = (win) => Object.keys(win.sessionStorage.dump()).sort();
|
||||
`aislo:draft:structures:${X}`,
|
||||
`aislo:draft:crossw:${X}:12`,
|
||||
`aislo:result:section-detail:${X}:12`,
|
||||
`b06:culvertopt:${X}:12`,
|
||||
`aislo:draft:d03-plan:${X}`,
|
||||
`aislo:draft:d04-assignees:${X}`,
|
||||
`aislo:draft:e05-report:${X}`,
|
||||
`d02_points_${X}`,
|
||||
`e02_ground_cache:${X}`,
|
||||
`e03_prob_cache:${X}`,
|
||||
`e04_settings:${X}`,
|
||||
`e06_rock_cache:${X}`,
|
||||
`e07_settings:${X}`,
|
||||
];
|
||||
const keep = [
|
||||
`aislo:draft:structures:${Y}`,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/* PLAN 472-8 헬퍼 — 브라우저 다시 셈(`B06_Section_Cross_Refresh_Calc.refreshDesigns`)을 Node 로.
|
||||
* 입력 argv[2] JSON {sections, plan: [[누가거리, 계획고]], standard} · 결과 = 표준출력 JSON(측점 설계 목록).
|
||||
* 짝 = `test_472_8_section_mode_default.py`. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
function loadTs(full) {
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request.endsWith(".json"))
|
||||
return require(
|
||||
request.startsWith("@util/")
|
||||
? path.join(ROOT, "common_util", request.slice(6))
|
||||
: path.join(path.dirname(full), request),
|
||||
);
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const { refreshDesigns } = loadTs(
|
||||
path.join(ROOT, "B06_Section", "logic", "B06_Section_Cross_Refresh_Calc.ts"),
|
||||
);
|
||||
const samples = job.plan.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m }));
|
||||
const alignment = {
|
||||
stations: samples.map((s) => ({ chainage_m: s.chainage_m, plan_elevation_m: s.elevation_m })),
|
||||
samples,
|
||||
balance: { cut_area_m2: 0, fill_area_m2: 0, cut_volume_m3: 0, fill_volume_m3: 0 },
|
||||
};
|
||||
const detail = { longitudinal: { design_profiles: [] }, cross_sections: job.sections };
|
||||
refreshDesigns(
|
||||
detail,
|
||||
{
|
||||
alignment,
|
||||
standard: job.standard,
|
||||
rockOffsets: [],
|
||||
rockDefault: job.rockDefault,
|
||||
cutSlopes: [],
|
||||
bermSpans: null,
|
||||
bermDepths: [],
|
||||
choices: [],
|
||||
shoulderExtends: [],
|
||||
canon: [],
|
||||
},
|
||||
null,
|
||||
);
|
||||
process.stdout.write(JSON.stringify(detail.cross_sections.map((s) => s.design)));
|
||||
@@ -1,111 +0,0 @@
|
||||
// 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다.
|
||||
// node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록
|
||||
// {op:"attach", entries:[관 응답], moves:[[옛, 새]], points:[{chainage_m, source}]} → attach 결과
|
||||
// {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값
|
||||
// {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(process.argv[2] ?? ".");
|
||||
const { build } = await import(
|
||||
pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href
|
||||
);
|
||||
|
||||
// 화면 칸 · 세션만 흉내 — 측점 칸은 값 · 클래스 · 이벤트만 쓴다
|
||||
class Node {
|
||||
constructor() {
|
||||
this.value = "";
|
||||
this.children = [];
|
||||
this.classList = { toggle() {}, add() {}, remove() {} };
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
appendChild(kid) {
|
||||
this.children.push(kid);
|
||||
return kid;
|
||||
}
|
||||
addEventListener() {}
|
||||
setAttribute() {}
|
||||
}
|
||||
const session = new Map();
|
||||
globalThis.document = { createElement: () => new Node(), createTextNode: () => new Node() };
|
||||
globalThis.window = {
|
||||
sessionStorage: {
|
||||
getItem: (k) => (session.has(k) ? session.get(k) : null),
|
||||
setItem: (k, v) => session.set(k, String(v)),
|
||||
removeItem: (k) => session.delete(k),
|
||||
},
|
||||
location: { origin: "http://localhost" },
|
||||
setTimeout: (...a) => setTimeout(...a),
|
||||
clearTimeout: (id) => clearTimeout(id),
|
||||
};
|
||||
globalThis.sessionStorage = globalThis.window.sessionStorage;
|
||||
globalThis.localStorage = globalThis.window.sessionStorage;
|
||||
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-save-payload-"));
|
||||
try {
|
||||
await build({
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
root,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@ui": path.join(root, "ui_template"),
|
||||
"@config": path.join(root, "config"),
|
||||
"@util": path.join(root, "common_util"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
lib: {
|
||||
entry: path.join(root, "resources/tester/helper_b05_save_payload_entry.ts"),
|
||||
formats: ["es"],
|
||||
fileName: "entry",
|
||||
},
|
||||
},
|
||||
});
|
||||
const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf-8"));
|
||||
const results = [];
|
||||
for (const job of jobs) {
|
||||
if (job.op === "attach") {
|
||||
const store = mod.createFacilityStore();
|
||||
store.replaceFromResponse(job.entries);
|
||||
// 지도에서 끈 관 — 화면이 끌기를 마치면 속성을 새 측점으로 옮김(onMove)
|
||||
for (const [from, to] of job.moves ?? []) store.move(from, to);
|
||||
results.push(store.attach(job.points));
|
||||
} else if (job.op === "station") {
|
||||
const fields = mod.stationFields("측점", () => job.interval);
|
||||
fields.write(job.chainage, job.interval);
|
||||
results.push(fields.read(job.interval, true));
|
||||
} else if (job.op === "culvert") {
|
||||
session.clear();
|
||||
session.set("opt", JSON.stringify(job.pending ?? {}));
|
||||
session.set("move", JSON.stringify(job.moves ?? {}));
|
||||
let sent = null;
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
const body = init.method === "PUT" ? JSON.parse(init.body) : null;
|
||||
if (body) sent = body.points;
|
||||
const payload = body ? { pipe_points: body.points } : { pipe_points: job.server };
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
await mod.flushCulvertOptions("p", "opt", "move");
|
||||
results.push(sent);
|
||||
} else {
|
||||
throw new Error(`모르는 작업 ${job.op}`);
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
} finally {
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
/* =============================================================================
|
||||
* helper_b05_save_payload_entry.ts
|
||||
* 저장 왕복 시험(`test_b05_b06_save_roundtrip.py`)이 **화면 코드 그대로** 저장 본문을 만들 때
|
||||
* 묶는 진입점 — 관 시설 되붙이기 · 구조물 측점 칸(표시 → 다시 읽기) · 구간값 예약 흘려보내기.
|
||||
* ========================================================================== */
|
||||
|
||||
export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility";
|
||||
export { stationFields } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
export { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
@@ -177,12 +177,6 @@ out.isItem = [
|
||||
item.isSummaryItem({ ...A, key: "" }),
|
||||
];
|
||||
out.readItem = [item.readSummaryItem(A)?.key ?? null, item.readSummaryItem("nope")];
|
||||
out.same = [
|
||||
item.sameSummaryItem(A, { ...A, name: "이름만 바뀜" }),
|
||||
item.sameSummaryItem(A, B),
|
||||
item.sameSummaryItem(null, null),
|
||||
item.sameSummaryItem(A, null),
|
||||
];
|
||||
out.label = [item.summaryLabel(null), item.summaryLabel(A)];
|
||||
out.tip = [item.summaryTooltip(null), item.summaryTooltip(A)];
|
||||
|
||||
|
||||
@@ -128,8 +128,7 @@ def test_arrow_step_one_metre(ts) -> None:
|
||||
|
||||
|
||||
def test_general_registry_skips_irregular(ts) -> None:
|
||||
assert ts["general"] == STATIONS # 구조물 측점 80.17(irregular)은 일반 측점 아님
|
||||
assert ts["defaultSnap"] == 80
|
||||
assert ts["defaultSnap"] == 80 # 구조물 측점 80.17(irregular)은 일반 측점 아님
|
||||
|
||||
|
||||
def test_form_input_integer(ts) -> None:
|
||||
|
||||
@@ -45,8 +45,6 @@ def test_남기기_그_프로젝트_초안만_둠(out):
|
||||
kept = [
|
||||
f"aislo:draft:crossw:{A}:12",
|
||||
f"aislo:draft:structures:{A}",
|
||||
f"b06:culvertopt:{A}:12",
|
||||
f"d02_points_{A}", # D02 옛 키도 초안(401)
|
||||
]
|
||||
assert out["keep"]["keys"] == sorted(MINE + UNRELATED + kept)
|
||||
assert out["keep"]["asked"] == 1
|
||||
@@ -54,7 +52,7 @@ def test_남기기_그_프로젝트_초안만_둠(out):
|
||||
|
||||
def test_같은_프로젝트_다시_들어오면_안_지움(out):
|
||||
assert out["same"]["asked"] == 0 and out["same"]["purged"] == 0
|
||||
assert len(out["same"]["keys"]) == 13
|
||||
assert len(out["same"]["keys"]) == 10
|
||||
|
||||
|
||||
def test_빈_초안은_고침_아님_취향은_둠(out):
|
||||
|
||||
@@ -141,7 +141,6 @@ def test_가게_결과_칸_메모리_우선(r):
|
||||
s = r["store"]
|
||||
assert s["beforeFlush"] is None and s["memoryRead"] is True # 끌기마다 세션에 안 씀
|
||||
assert s["afterDelay"] == [{"key": "0", "chainageM": 0}] # 멈춘 뒤 한 번
|
||||
assert s["flushed"] == {"schema_version": 1}
|
||||
assert s["generationSession"] == [] # 차례 번호는 메모리만
|
||||
assert s["cleared"] == {"stations": None, "profile": None} # clearResults = 메모리도 버림
|
||||
assert s["footprintRevived"] is None # 지운 뒤 미뤄 둔 쓰기가 되살리지 않음
|
||||
|
||||
@@ -128,10 +128,10 @@ def _py_record(station: dict) -> dict | None:
|
||||
|
||||
DRIVER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { planFootprint } from "./B05_Profile_PlanFootprint.js";
|
||||
import { footprintRecord, slopePieces } from "./B05_Profile_PlanFootprint.js";
|
||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf-8"));
|
||||
writeFileSync(process.argv[3], JSON.stringify(cases.map((c) =>
|
||||
planFootprint(c.route, c.stations, c.start, c.end, c.step))));
|
||||
slopePieces(c.route, c.stations.map(footprintRecord).filter(Boolean), c.start, c.end, c.step))));
|
||||
"""
|
||||
|
||||
CASES = [(0.0, 400.0, 2.0), (35.0, 130.0, 2.0), (0.0, 400.0, 10.0)]
|
||||
|
||||
@@ -73,5 +73,5 @@ def test_등록부_옛_키는_프로젝트_품음_사진_칸은_그_프로젝트
|
||||
) # fmt: skip
|
||||
assert run.returncode == 0, run.stderr
|
||||
out = json.loads(run.stdout)
|
||||
assert out["legacy"] and all("PX" in key for _, key in out["legacy"])
|
||||
assert out["legacy"] == [] # 옛 키 옮김 지움(472-8)
|
||||
assert out["keys"] == ["aislo:draft:crossw:p1:7", "aislo:draft:pipes:p1"]
|
||||
|
||||
@@ -47,12 +47,12 @@ def test_다른_프로젝트_화면은_새로_엶(out):
|
||||
|
||||
def test_떠남은_그_프로젝트_몫만_지움(out):
|
||||
leave = out["leave"]
|
||||
assert leave["keys"] == leave["expected"] # B · D · E · 옛 키 · 준비 표식 0 · 밖 칸 그대로
|
||||
assert leave["keys"] == leave["expected"] # B · D · E · 준비 표식 0 · 밖 칸 그대로
|
||||
assert leave["entered"] == X # 탭 프로젝트 표식은 남김
|
||||
assert leave["local"] == ["1", "1"] # 취향 · CAD 복구본
|
||||
assert leave["idb"] == [f"{Y}|u1"] # 보관함 = X 줄만 지움
|
||||
assert leave["otherTabKept"] == "1" # 다른 탭 세션은 안 건드림
|
||||
assert leave["notified"] == 13 # 지운 칸마다 지움 알림 — 설계 가게가 미룬 쓰기(1 s)를 거둠
|
||||
assert leave["notified"] == 6 # 지운 칸마다 지움 알림 — 설계 가게가 미룬 쓰기(1 s)를 거둠
|
||||
|
||||
|
||||
def test_떠남_물음_한_번(out):
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""PLAN 472-8 (요구 409) — 단면 유형이 없는 (옛) 설계의 기본 = 상단측 깎기 하나
|
||||
(지형이 정함 · 모르면 좌).
|
||||
|
||||
못박는 것
|
||||
- 서버 [저장] 파이썬 길(`stored_section_mode`)
|
||||
= Node 길(TS `B06_Section_Cross_Save` storedOptions).
|
||||
- 브라우저 다시 셈(TS `B06_Section_Cross_Refresh_Calc`) = 같은 규칙.
|
||||
- 셋 다 「단면 유형 없음」 = 「상단측 깎기를 적어 넣음」과 0 차
|
||||
· 우 상단측 측점은 옛 좌 깎기와 다름.
|
||||
- 묶음 cross 단계(`B06_Section_Cross_Stage`) 기본도 상단측
|
||||
(`uphillSide` 다시 씀 · 좌 고정 폴백 없음).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_472_8_section_mode_default.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
for path in (ROOT, HERE):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from test_511_b4_cross_save_mirror import SURFACE, _facilities, _npz, _plan, _sections # noqa: E402
|
||||
|
||||
from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild # noqa: E402
|
||||
from B06_Section.B06_Section_Engine_Design_Node import full_standard # noqa: E402
|
||||
from config.config_system import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
|
||||
|
||||
def _uphill(index: int) -> str | None:
|
||||
return None if index == 3 else ("right" if index % 2 else "left")
|
||||
|
||||
|
||||
def _expected(index: int) -> str:
|
||||
return f"{_uphill(index) or 'left'}_cut"
|
||||
|
||||
|
||||
def _stripped(sections: list[dict], mode=None) -> list[dict]:
|
||||
"""단면 유형을 지운 옛 설계 · `mode(index)` 가 오면 그 값을 적어 넣은 짝."""
|
||||
out = copy.deepcopy(sections)
|
||||
for index, section in enumerate(out):
|
||||
section["uphill_side"] = _uphill(index)
|
||||
section["design"].pop("section_mode", None)
|
||||
if mode:
|
||||
section["design"]["section_mode"] = mode(index)
|
||||
return out
|
||||
|
||||
|
||||
def _server(root: Path, longitudinal: dict, sections: list[dict]) -> tuple[list, list]:
|
||||
py, node = copy.deepcopy(sections), copy.deepcopy(sections)
|
||||
prebuild._enforce_on_python(longitudinal, py, root, None, SURFACE)
|
||||
grid = prebuild.design_grid_file(root, SURFACE)
|
||||
assert prebuild._enforce_on_node(longitudinal, node, root, None, grid)
|
||||
return [s["design"] for s in py], [s["design"] for s in node]
|
||||
|
||||
|
||||
def _browser(tmp_path: Path, sections: list[dict]) -> list[dict]:
|
||||
job = tmp_path / "job.json"
|
||||
plan = [[s["chainage_m"], _plan(s["chainage_m"])] for s in sections]
|
||||
job.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sections": sections,
|
||||
"plan": plan,
|
||||
"standard": full_standard(None),
|
||||
"rockDefault": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_472_8_section_mode.cjs"), str(job)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_단면_유형_없음_상단측_서버_두_길_브라우저_같음(tmp_path, monkeypatch):
|
||||
root = tmp_path / "p"
|
||||
_npz(root)
|
||||
_facilities(monkeypatch)
|
||||
longitudinal, sections = _sections(root)
|
||||
bare = _stripped(sections)
|
||||
|
||||
py, node = _server(root, longitudinal, bare)
|
||||
assert py == node # 파이썬 길 = Node 길(TS Cross_Save)
|
||||
py_set, _ = _server(root, longitudinal, _stripped(sections, _expected))
|
||||
assert py == py_set # 없음 = 상단측 적어 넣음
|
||||
py_left, _ = _server(root, longitudinal, _stripped(sections, lambda _i: "left_cut"))
|
||||
assert any(a != b for a, b in zip(py, py_left)) # 옛 좌 고정과 다름(우 상단측 측점)
|
||||
|
||||
browser = _browser(tmp_path, bare)
|
||||
assert browser == _browser(tmp_path, _stripped(sections, _expected))
|
||||
assert [d["section_mode"] for d in browser] == [d["section_mode"] for d in py]
|
||||
assert browser != _browser(tmp_path, _stripped(sections, lambda _i: "left_cut"))
|
||||
|
||||
|
||||
def test_묶음_cross_단계_기본은_상단측():
|
||||
stage = (ROOT / "B06_Section/logic/B06_Section_Cross_Stage.ts").read_text(encoding="utf-8")
|
||||
assert 'sectionMode: `${uphillSide(station) ?? "left"}_cut`' in stage
|
||||
assert '"left_cut"' not in stage
|
||||
@@ -1,518 +0,0 @@
|
||||
"""B05 · B06 저장 왕복 — 사용자가 바꾼 값이 저장 · 다시 읽기 뒤 그대로인가(PLAN 19장).
|
||||
|
||||
환경 배정 storage 프로젝트(랩탑_보조)의 **사본**에서 화면 저장 흐름과 같은 차례로 서버 API 를
|
||||
부르고, 다시 읽은 값(pipe_points.json · structures.json · 종단 파일 · 응답)을 사용자가 고른 값과
|
||||
0 차이로 대조. 저장 본문은 화면 코드 그대로(`helper_b05_save_payload.mjs` — 시설 되붙이기 ·
|
||||
구조물 측점 칸 · 구간값 예약).
|
||||
|
||||
- 파일: 프로젝트 폴더를 임시 폴더로 복사(큰 원본 · 점군 제외) · `STORAGE_BASE_DIR` 를 그리로 돌림.
|
||||
- DB: 읽기는 진짜 DB(같은 프로젝트 행) · 세션을 읽기 전용으로 열어 쓰기가 새지 않음 ·
|
||||
쓰는 함수는 받은 값을 적어 두는 가짜로 갈음(DB 에 남기는 값도 보낸 값과 대조).
|
||||
- 원본 프로젝트 · 사용자 프로젝트 · DB 는 바뀌지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
|
||||
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
|
||||
# 저장 흐름이 읽고 쓰는 것만 — 원본 입력(B03) · 점군(structured.npz 등)은 안 씀
|
||||
_COPY_DIRS = ("B04_PreProcess/drainage", "B05_Profile", "B06_Section")
|
||||
_COPY_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
pytestmark = pytest.mark.skipif(not SOURCE.is_dir(), reason="배정 storage 프로젝트가 없음")
|
||||
|
||||
|
||||
def _copy_project(dest: Path) -> None:
|
||||
for rel in _COPY_DIRS:
|
||||
shutil.copytree(SOURCE / rel, dest / rel)
|
||||
for rel in ("B04_PreProcess/models", "B04_PreProcess/processed"):
|
||||
(dest / rel).mkdir(parents=True, exist_ok=True)
|
||||
for item in (SOURCE / rel).iterdir():
|
||||
if item.is_file() and item.stat().st_size <= _COPY_MAX_BYTES:
|
||||
shutil.copy2(item, dest / rel / item.name)
|
||||
for item in SOURCE.iterdir():
|
||||
if item.is_file():
|
||||
shutil.copy2(item, dest / item.name)
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""DB 에 쓰는 함수 대신 — 받은 값을 적어 둠."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def fake(self, name: str, result: Any = None):
|
||||
async def _fake(*args: Any, **kwargs: Any) -> Any:
|
||||
self.calls.append((name, {"args": args[1:], **kwargs}))
|
||||
return result
|
||||
|
||||
return _fake
|
||||
|
||||
def of(self, name: str) -> list[dict[str, Any]]:
|
||||
return [call for key, call in self.calls if key == name]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api(tmp_path, monkeypatch):
|
||||
aiomysql = pytest.importorskip("aiomysql")
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import B04_PreProcess.B04_PreProcess_Router_Basins as basins
|
||||
import B05_Profile.B05_Profile_Router as route
|
||||
import B05_Profile.B05_Profile_Router_Confirm as route_confirm
|
||||
import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle
|
||||
import B05_Profile.B05_Profile_Structures_Router as structures
|
||||
import B06_Section.B06_Section_Repository_Bulk as bulk
|
||||
import B06_Section.B06_Section_Router_Confirm as sections
|
||||
import B06_Section.B06_Section_Router_Stations as station_router
|
||||
import common_util.common_util_storage as storage
|
||||
import config.config_db as config_db
|
||||
from config import config_system as cfg
|
||||
|
||||
storage_root = tmp_path / "storage"
|
||||
_copy_project(storage_root / "1" / "3" / PROJECT_ID)
|
||||
monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root))
|
||||
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(route, "update_longitudinal_grade_summary", rec.fake("grade_summary"))
|
||||
monkeypatch.setattr(structures, "_invalidate_downstream", rec.fake("invalidate", True))
|
||||
monkeypatch.setattr(lifecycle, "confirm_route", rec.fake("confirm_route"))
|
||||
monkeypatch.setattr(lifecycle, "complete_stage", rec.fake("complete_stage"))
|
||||
monkeypatch.setattr(bulk, "merge_cross_section_designs", rec.fake("designs", 0))
|
||||
monkeypatch.setattr(sections, "merge_cross_section_designs", rec.fake("designs", 0))
|
||||
monkeypatch.setattr(sections, "merge_longitudinal_section_options", rec.fake("options"))
|
||||
monkeypatch.setattr(sections, "merge_longitudinal_section_data", rec.fake("data"))
|
||||
monkeypatch.setattr(sections, "confirm_sections_for_route", rec.fake("confirm_sections"))
|
||||
monkeypatch.setattr(sections, "confirm_route_status", rec.fake("confirm_route"))
|
||||
monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage"))
|
||||
monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage"))
|
||||
monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute", True))
|
||||
# 272 — 측점을 다시 맞춘 뒤 정본에 없는 측점의 횡단 행 지우기
|
||||
monkeypatch.setattr(route_confirm, "sync_cross_section_rows", rec.fake("sync_rows", 0))
|
||||
monkeypatch.setattr(
|
||||
route_confirm, "update_longitudinal_grade_summary", rec.fake("grade_summary")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
station_router, "update_longitudinal_grade_summary", rec.fake("grade_summary")
|
||||
)
|
||||
real_known = sections.get_cross_section_chainages
|
||||
|
||||
async def spy_known(connection, route_id):
|
||||
rows = await real_known(connection, route_id)
|
||||
rec.calls.append(("known", {"chainages": list(rows)}))
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(sections, "get_cross_section_chainages", spy_known)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app):
|
||||
try:
|
||||
pool = await aiomysql.create_pool(
|
||||
host=cfg.DB_HOST,
|
||||
port=cfg.DB_PORT,
|
||||
db=cfg.DB_NAME,
|
||||
user=cfg.DB_USER,
|
||||
password=cfg.DB_PASSWORD,
|
||||
minsize=1,
|
||||
maxsize=6,
|
||||
autocommit=True,
|
||||
charset="utf8mb4",
|
||||
# 읽기 전용 세션 — 가짜로 못 갈음한 쓰기가 있으면 DB 를 바꾸지 않고 터짐
|
||||
init_command="SET time_zone='+00:00', SESSION tx_read_only=1",
|
||||
)
|
||||
except Exception as exc: # DB 가 안 닿는 곳
|
||||
pytest.skip(f"DB 에 안 닿음: {exc}")
|
||||
config_db.db_pool = pool
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
config_db.db_pool = None
|
||||
pool.close()
|
||||
await pool.wait_closed()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
for module in (basins, route, lifecycle, structures, sections):
|
||||
app.include_router(module.router)
|
||||
with TestClient(app) as client:
|
||||
client.rec = rec # type: ignore[attr-defined]
|
||||
client.root = storage_root / "1" / "3" / PROJECT_ID # type: ignore[attr-defined]
|
||||
yield client
|
||||
|
||||
|
||||
# ── 화면 코드 그대로 본문 만들기(node) ────────────────────────────────────────
|
||||
HELPER = Path(__file__).with_name("helper_b05_save_payload.mjs")
|
||||
STATION_INTERVAL_M = 20.0
|
||||
# 화면이 관 옵션 수정 끝에 측점을 다시 읽어 이만큼 넘게 다르면 관을 옮김
|
||||
# (`B05_Profile_UI_Drainage_Panel.ts` `updatePipeFacility`)
|
||||
PIPE_MOVE_THRESHOLD_M = 0.005
|
||||
|
||||
|
||||
def _screen(jobs: list[dict[str, Any]]) -> list[Any]:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
out = subprocess.run(
|
||||
["node", str(HELPER), str(ROOT)],
|
||||
input=json.dumps(jobs),
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
check=True,
|
||||
)
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
# ── 비교 모양 ─────────────────────────────────────────────────────────────────
|
||||
LONGITUDINAL = "B06_Section/longitudinal/longitudinal.json"
|
||||
PIPES_FILE = "B04_PreProcess/drainage/edits/pipe_points.json"
|
||||
STRUCTURES_FILE = "B05_Profile/route/structures.json"
|
||||
|
||||
|
||||
def _key(chainage: float) -> str:
|
||||
return f"{chainage:.2f}"
|
||||
|
||||
|
||||
def _pipes_view(points: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""관 — 측점(0.01) → 시설 · 구간 · 옵션(좌표 · 생성 사유는 뺌)."""
|
||||
return {
|
||||
_key(p["chainage_m"]): {
|
||||
"facility": p.get("facility", "pipe"),
|
||||
"span": (p.get("start_m"), p.get("end_m")),
|
||||
"options": p.get("options") or {},
|
||||
}
|
||||
for p in points
|
||||
}
|
||||
|
||||
|
||||
def _read(root: Path, rel: str) -> Any:
|
||||
return json.loads((root / rel).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _strip(point: dict[str, Any]) -> dict[str, Any]:
|
||||
"""화면이 서버에 되보내는 관 — 좌표는 서버가 다시 냄(`DetailPipeInput`)."""
|
||||
return {k: v for k, v in point.items() if k != "lonlat"}
|
||||
|
||||
|
||||
class Flow:
|
||||
"""화면 저장 흐름 하나(B05 [임시저장] · B06 [저장]).
|
||||
|
||||
사용자가 고른 값(`want_*`)을 따로 적어 두고, 화면 코드가 만든 본문을 서버에 보낸 뒤
|
||||
다시 읽은 값을 고른 값과 대조. 바꿀 대상은 사본에 있는 것 중 자리 순번으로 고름.
|
||||
"""
|
||||
|
||||
def __init__(self, api) -> None:
|
||||
self.api = api
|
||||
self.base = f"/api/projects/{PROJECT_ID}"
|
||||
latest = api.get(f"{self.base}/route/latest").json()
|
||||
self.route_id = latest["route"]["id"]
|
||||
self.surface = latest["surface_params"]
|
||||
self.model_id = latest["route"]["surface_model_id"]
|
||||
self.diffs: list[str] = []
|
||||
self.sent_irregular: list[float] = []
|
||||
self._plan()
|
||||
|
||||
def server_pipes(self) -> list[dict[str, Any]]:
|
||||
got = self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
||||
return [_strip(p) for p in got]
|
||||
|
||||
# ── 사용자가 고른 값 ──
|
||||
def _plan(self) -> None:
|
||||
pipes = self.server_pipes()
|
||||
if len(pipes) < 9:
|
||||
pytest.skip(f"관이 {len(pipes)} 개 — 시험에 9 개 넘게 필요")
|
||||
self.pipes0 = pipes
|
||||
ch = [p["chainage_m"] for p in pipes]
|
||||
self.pipe_move = (ch[2], round(ch[2] + 5.35, 2)) # 관 옮김 — 시설 · 옵션이 따라가야 함
|
||||
self.pipe_option = (ch[5], {"pipe_diameter_mm": 1350}) # 옵션만 — 자리 그대로
|
||||
self.culvert = (ch[8], {"pipe_diameter_mm": 1200}) # B06 조정창 구간값 예약
|
||||
self.stale_move = (ch[4], round(ch[4] + 4.76, 2)) # 옛 세션에 남은 관 이동 예약
|
||||
self.pipe_new = round((ch[6] + ch[7]) / 2, 2) # 새 관 하나(맨 배관)
|
||||
want = []
|
||||
for p in pipes:
|
||||
item = dict(p)
|
||||
if item["chainage_m"] == self.pipe_move[0]:
|
||||
item.update(chainage_m=self.pipe_move[1], source="user")
|
||||
elif item["chainage_m"] == self.pipe_option[0]:
|
||||
item["options"] = {**(item.get("options") or {}), **self.pipe_option[1]}
|
||||
elif item["chainage_m"] == self.culvert[0]:
|
||||
item["options"] = {**(item.get("options") or {}), **self.culvert[1]}
|
||||
want.append(item)
|
||||
want.append({"chainage_m": self.pipe_new, "source": "user"})
|
||||
self.want_pipes = sorted(want, key=lambda x: x["chainage_m"])
|
||||
|
||||
got = self.api.get(f"{self.base}/route/structures").json()
|
||||
self.revision0, items = got["revision"], got["structures"]
|
||||
spans = [s for s in items if s.get("start_m") is not None and s.get("end_m") is not None]
|
||||
if len(spans) < 3:
|
||||
pytest.skip("구간 구조물이 3 개 넘게 필요")
|
||||
moved, optioned, memoed = spans[0], spans[1], spans[2]
|
||||
width = moved["end_m"] - moved["start_m"]
|
||||
to = round(moved["start_m"] + 1.37, 2) # 측점 칸에 소수 2자리로 쳐 넣음
|
||||
self.struct_move = (moved["structure_id"], to, round(to + width, 2))
|
||||
self.want_structures = json.loads(json.dumps(items))
|
||||
for s in self.want_structures:
|
||||
if s["structure_id"] == moved["structure_id"]:
|
||||
s.update(chainage_m=to, start_m=to, end_m=round(to + width, 2))
|
||||
s["memo"] = "옮긴 뒤 메모" # 옮긴 칸에서 메모만 한 번 더 고침
|
||||
elif s["structure_id"] == optioned["structure_id"]:
|
||||
s["options"] = {**s["options"], "height_m": 3}
|
||||
s["memo"] = "왕복 시험 메모"
|
||||
elif s["structure_id"] == memoed["structure_id"]:
|
||||
s["memo"] = "메모만 바꿈"
|
||||
|
||||
lon = _read(self.api.root, LONGITUDINAL)
|
||||
grid = [s for s in lon["stations"] if s.get("kind") == "regular"]
|
||||
g0, g1 = grid[4], grid[len(grid) // 2]
|
||||
self.offsets = {f"{g0['chainage_m']:.3f}": 0.35, f"{g1['chainage_m']:.3f}": -0.2}
|
||||
side = grid[3].get("uphill_side")
|
||||
self.uphill = (grid[3]["chainage_m"], "left" if side != "left" else "right")
|
||||
self.cross_patches = [
|
||||
{
|
||||
"chainage_m": g0["chainage_m"],
|
||||
"rock_boundary_offset_m": -0.8,
|
||||
"cut_slope_ratio_user": 0.5,
|
||||
},
|
||||
{"chainage_m": self.pipe_move[1], "pipe_length_m": 9.0},
|
||||
]
|
||||
|
||||
# ── 화면 코드가 만드는 본문 ──
|
||||
def b05_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
"""B05 `savePipes` — 화면 관 목록(측점 · 사유) + 시설 보관소 `attach`."""
|
||||
(station,) = _screen(
|
||||
[{"op": "station", "chainage": self.pipe_option[0], "interval": STATION_INTERVAL_M}]
|
||||
)
|
||||
entries = [dict(p) for p in self.pipes0]
|
||||
screen_ch = {}
|
||||
for p in self.want_pipes:
|
||||
c = p["chainage_m"]
|
||||
if c == self.pipe_option[0] and abs(station - c) > PIPE_MOVE_THRESHOLD_M:
|
||||
c = station # 옵션 폼 확정이 측점을 표시값(0.1)에서 다시 읽어 관을 옮김
|
||||
screen_ch[p["chainage_m"]] = c
|
||||
for e in entries: # 폼에서 고친 시설 정보는 그 관의 (새) 자리로 보관소에 들어감
|
||||
if e["chainage_m"] == self.pipe_option[0]:
|
||||
e["chainage_m"] = screen_ch[self.pipe_option[0]]
|
||||
e["options"] = {**(e.get("options") or {}), **self.pipe_option[1]}
|
||||
points = [
|
||||
{
|
||||
"chainage_m": screen_ch[p["chainage_m"]],
|
||||
"source": "confirmed" if p.get("source") == "user" else p.get("source", "user"),
|
||||
}
|
||||
for p in self.want_pipes
|
||||
]
|
||||
job = {"op": "attach", "entries": entries, "moves": [self.pipe_move], "points": points}
|
||||
(attached,) = _screen([job])
|
||||
return attached
|
||||
|
||||
def b06_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
"""B06 `persistPipes` — 화면 관 상태 그대로(구간값은 예약으로 따로 나감)."""
|
||||
before = {p["chainage_m"]: p.get("options") for p in self.pipes0}
|
||||
out = []
|
||||
for p in self.want_pipes:
|
||||
item = dict(p)
|
||||
if p["chainage_m"] == self.culvert[0]:
|
||||
item.pop("options", None)
|
||||
if before[self.culvert[0]]:
|
||||
item["options"] = before[self.culvert[0]]
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
def screen_structures(self, *, reread: bool) -> list[dict[str, Any]]:
|
||||
"""구조물 — 옮긴 칸에서 메모를 고치면 `commit` 이 측점 칸(표시 0.1)을 다시 읽음."""
|
||||
items = json.loads(json.dumps(self.want_structures))
|
||||
if not reread:
|
||||
return items
|
||||
sid, start, end = self.struct_move
|
||||
ch = next(s for s in items if s["structure_id"] == sid)
|
||||
a, b, c = _screen(
|
||||
[
|
||||
{"op": "station", "chainage": v, "interval": STATION_INTERVAL_M}
|
||||
for v in (start, ch["chainage_m"], end)
|
||||
]
|
||||
)
|
||||
ch.update(start_m=a, chainage_m=b, end_m=c)
|
||||
return items
|
||||
|
||||
def culvert_payload(self, current: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||
"""`flushCulvertOptions` — 구간값 예약 + 옛 세션 이동 예약을 서버 목록에 얹음."""
|
||||
pending = {_key(self.culvert[0]): self.culvert[1]}
|
||||
moves = {_key(self.stale_move[0]): self.stale_move[1]}
|
||||
(body,) = _screen(
|
||||
[{"op": "culvert", "server": current, "pending": pending, "moves": moves}]
|
||||
)
|
||||
return body
|
||||
|
||||
# ── API(화면 Api_Fetch 와 같은 경로 · 본문) ──
|
||||
def put_pipes(self, pipes: list[dict[str, Any]]) -> None:
|
||||
res = self.api.put(f"{self.base}/drainage/pipe-points", json={"points": pipes})
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def flush_culvert(self) -> None:
|
||||
body = self.culvert_payload(
|
||||
self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
||||
)
|
||||
if body is not None:
|
||||
self.put_pipes(body)
|
||||
|
||||
def put_alignment(self) -> None:
|
||||
body = {"route_id": self.route_id, "station_offsets": self.offsets, "curve_radii": {}}
|
||||
res = self.api.put(f"{self.base}/route/profile-alignment", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def put_structures(self, items: list[dict[str, Any]]) -> None:
|
||||
revision = self.api.get(f"{self.base}/route/structures").json()["revision"]
|
||||
body = {"base_revision": revision, "structures": items}
|
||||
res = self.api.put(f"{self.base}/route/structures", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def confirm(self, *, with_irregular: bool) -> None:
|
||||
body: dict[str, Any] = {
|
||||
"uphill_overrides": [{"chainage_m": self.uphill[0], "side": self.uphill[1]}]
|
||||
}
|
||||
if with_irregular: # B05 — 관 투영(물넘이포장 뺌) · A군 구조물
|
||||
self.sent_irregular = [
|
||||
p["chainage_m"] for p in self.server_pipes() if p.get("facility") != "ford_pavement"
|
||||
]
|
||||
body.update(
|
||||
filter_key=self.surface["source_filter"],
|
||||
method=self.surface["method"],
|
||||
smooth=self.surface["smooth"],
|
||||
surface_model_id=self.model_id,
|
||||
irregular_stations=[
|
||||
{"chainage_m": c, "structure": "배관"} for c in self.sent_irregular
|
||||
],
|
||||
)
|
||||
res = self.api.post(f"{self.base}/route/confirm?mark_stage_complete=false", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def save_sections(self) -> None:
|
||||
body = {"cross_patches": self.cross_patches}
|
||||
res = self.api.post(f"{self.base}/sections/{self.route_id}/save", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
# ── 다시 읽기 · 대조(사용자가 고른 값 기준) ──
|
||||
def check(self) -> None:
|
||||
root, d = self.api.root, self.diffs
|
||||
want = _pipes_view(self.want_pipes)
|
||||
for where, got in (
|
||||
("응답", _pipes_view(self.server_pipes())),
|
||||
("pipe_points.json", _pipes_view(_read(root, PIPES_FILE)["points"])),
|
||||
):
|
||||
for k in sorted(set(want) | set(got), key=float):
|
||||
if want.get(k) != got.get(k):
|
||||
d.append(f"관 {k} ({where}) — 고름 {want.get(k)} · 읽음 {got.get(k)}")
|
||||
saved = {s["structure_id"]: s for s in _read(root, STRUCTURES_FILE)["structures"]}
|
||||
for s in self.want_structures:
|
||||
got_s = saved.get(s["structure_id"], {})
|
||||
for field in ("chainage_m", "start_m", "end_m", "options", "memo", "placement_source"):
|
||||
if s.get(field) != got_s.get(field):
|
||||
d.append(
|
||||
f"구조물 {s['type_id']} {field} — "
|
||||
f"고름 {s.get(field)!r} · 읽음 {got_s.get(field)!r}"
|
||||
)
|
||||
lon = _read(root, LONGITUDINAL)
|
||||
edits = (lon["profile_alignment"].get("edits") or {}).get("station_offsets") or {}
|
||||
if edits != self.offsets:
|
||||
d.append(f"계획고 수정 — 고름 {self.offsets} · 읽음 {edits}")
|
||||
up = next((s for s in lon["stations"] if abs(s["chainage_m"] - self.uphill[0]) < 0.005), {})
|
||||
if (up.get("uphill_side"), up.get("uphill_side_source")) != (self.uphill[1], "user"):
|
||||
d.append(
|
||||
f"상단측 {self.uphill[0]} — 고름 {self.uphill[1]} · "
|
||||
f"읽음 {up.get('uphill_side')}/{up.get('uphill_side_source')}"
|
||||
)
|
||||
for c in self.sent_irregular:
|
||||
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
|
||||
if not any(abs(n - c) < 0.005 for n in near):
|
||||
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
|
||||
# 소수 측점 횡단(PLAN 20장) — 그 자리 횡단 파일이 소수 2자리 이름으로 따로 있음
|
||||
name = cross_filename(c)
|
||||
if not (root / "B06_Section" / "cross_sections" / name).is_file():
|
||||
d.append(f"관 측점 {c} — 횡단 파일 {name} 없음")
|
||||
self._check_designs()
|
||||
|
||||
def _check_designs(self) -> None:
|
||||
"""횡단 patch — DB 로 가는 값이 고른 값과 같고, 받을 행이 있어야 함(없으면 DB 가 버림)."""
|
||||
rec = self.api.rec
|
||||
known = [c for call in rec.of("known") for c in call["chainages"]]
|
||||
designs = rec.of("designs")
|
||||
made = [e[0] for call in designs if call.get("replace") for e in call["entries"]]
|
||||
patched = {
|
||||
round(e[0], 2): e[1]
|
||||
for call in designs
|
||||
if call.get("replace") is False
|
||||
for e in call["entries"]
|
||||
}
|
||||
for patch in self.cross_patches:
|
||||
ch = patch["chainage_m"]
|
||||
body = {k: v for k, v in patch.items() if k != "chainage_m"}
|
||||
got = patched.get(round(ch, 2))
|
||||
if got is None or any(got.get(k) != v for k, v in body.items()):
|
||||
self.diffs.append(f"횡단 patch {ch} — 고름 {body} · DB 로 {got}")
|
||||
elif not any(abs(float(k) - ch) < 0.01 for k in [*known, *made]):
|
||||
self.diffs.append(f"횡단 patch {ch} — 받을 행이 없어 DB 가 버림")
|
||||
|
||||
|
||||
def _snapshot(root: Path) -> dict[str, Any]:
|
||||
lon = _read(root, LONGITUDINAL)
|
||||
return {
|
||||
"pipe_points.json": _read(root, PIPES_FILE)["points"],
|
||||
"structures.json": _read(root, STRUCTURES_FILE)["structures"],
|
||||
"계획고 수정": lon["profile_alignment"].get("edits"),
|
||||
"종단 측점": [
|
||||
(s["chainage_m"], s.get("kind"), s.get("uphill_side")) for s in lon["stations"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _resave_unchanged(flow: Flow) -> None:
|
||||
"""다시 열어 아무것도 안 바꾸고 [저장] — 저장본이 한 글자도 안 바뀌어야 함."""
|
||||
flow.put_pipes(flow.server_pipes())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
|
||||
|
||||
|
||||
# 두 페이지 모두 저장 한 줄기(`A00_Common/design/design_save.ts` `DESIGN_SAVE_ORDER`)를 탄다 —
|
||||
# 관 → 구조물 → 계획고 → 암거 옵션(구간값) → 오르막(상단측 확정) → 횡단(계획서 24-2a).
|
||||
def _b05_save(flow: Flow) -> None:
|
||||
"""B05 [임시저장] — 오르막 차례에 관 측점(비정규 측점)도 함께 확정."""
|
||||
flow.put_pipes(flow.b05_pipe_payload())
|
||||
flow.put_structures(flow.screen_structures(reread=True))
|
||||
flow.put_alignment()
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
def _b06_save(flow: Flow) -> None:
|
||||
"""B06 [저장] — 오르막 차례는 상단측만 확정."""
|
||||
flow.put_pipes(flow.b06_pipe_payload())
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.put_alignment()
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=False)
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("save", [_b05_save, _b06_save], ids=["B05", "B06"])
|
||||
def test_저장_왕복_0_차이(api, save):
|
||||
flow = Flow(api)
|
||||
save(flow)
|
||||
flow.check()
|
||||
before = _snapshot(api.root)
|
||||
_resave_unchanged(flow)
|
||||
after = _snapshot(api.root)
|
||||
flow.diffs += [f"다시 열어 그대로 저장 — {k} 가 바뀜" for k in before if before[k] != after[k]]
|
||||
assert not flow.diffs, "\n".join(flow.diffs)
|
||||
@@ -97,56 +97,3 @@ def test_source_old_diameter_formula_removed():
|
||||
assert "diameterMm ?? 1000" not in RENDER
|
||||
assert "findMinCoverViolations" in GUARD
|
||||
assert "minCoverTargets: () => minCoverTargets" in PANEL
|
||||
|
||||
|
||||
# 구간 쉬프트 · R 입력 = `_Alignment_Edit.ts`(700줄 나눔 · 511-B B-3) — 두 파일을 한 글로 봄
|
||||
ALIGNMENT = "".join(
|
||||
(PROJECT_ROOT / "B05_Profile" / "logic" / name).read_text(encoding="utf-8")
|
||||
for name in ("B05_Profile_UI_Profile_Alignment.ts", "B05_Profile_UI_Profile_Alignment_Edit.ts")
|
||||
)
|
||||
|
||||
|
||||
def test_shift_moves_hinges_not_fixed_ends():
|
||||
"""쉬프트 재정의(2026-08-23 사용자 개념 확정) 소스 잠금:
|
||||
고정점(BP·EP·배수 앵커)은 안 움직이고 안쪽 미틸트 측점 외곽 2개가 힌지로
|
||||
승격(라운드 생성)된다. 힌지 부족이면 쉬프트 불가(버튼 숨김)."""
|
||||
assert "export function shiftMovingPoints" in ALIGNMENT
|
||||
assert "export function controlElevationAt" in ALIGNMENT
|
||||
body = ALIGNMENT[ALIGNMENT.index("export function shiftSegment") :]
|
||||
assert "shiftMovingPoints(current, segment)" in body
|
||||
assert "if (!moving) return edits;" in body
|
||||
assert "FEEDBACK_PASSES" in body # 라운드(중앙종거) 낀 자리도 1클릭 = 정확 delta.
|
||||
EDIT = (PROJECT_ROOT / "B05_Profile" / "B05_Profile_UI_Profile_Edit.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# 구간 쉬프트 버튼(⬆⬇)과 그 노출 조건(canShift)은 2026-09-02 지시로 삭제됐다.
|
||||
# 기하 함수는 남겨 두기로 했으므로(PLAN.md) 위 존재 검사는 그대로 두고 배선만 잠근다.
|
||||
assert "canShift" not in EDIT
|
||||
assert "canShift:" not in RENDER
|
||||
|
||||
|
||||
def _moving_points(stations, edited, seg_from, seg_to):
|
||||
"""TS shiftMovingPoints와 같은 규칙 — 끝점이 틸팅점이면 그 점, 아니면 안쪽 외곽 측점."""
|
||||
interior = [s for s in stations if seg_from + 1e-6 < s < seg_to - 1e-6]
|
||||
left = seg_from if seg_from in edited else (interior[0] if interior else None)
|
||||
right = seg_to if seg_to in edited else (interior[-1] if interior else None)
|
||||
if left is None or right is None or right - left < 1e-6:
|
||||
return None
|
||||
return (left, right)
|
||||
|
||||
|
||||
def test_moving_points_promote_interior_hinges():
|
||||
"""앵커-앵커 구간: 안쪽 미틸트 측점 외곽 2개(첫·끝)가 힌지가 된다."""
|
||||
assert _moving_points([20, 40, 60, 80], set(), 0, 84.3) == (20, 80)
|
||||
|
||||
|
||||
def test_moving_points_need_two_hinges():
|
||||
"""안쪽 측점이 1개 이하면 힌지 둘을 못 만든다 — 쉬프트 불가."""
|
||||
assert _moving_points([20], set(), 0, 30) is None
|
||||
assert _moving_points([], set(), 0, 30) is None
|
||||
|
||||
|
||||
def test_moving_points_reuse_tilted_ends():
|
||||
"""사용자가 틸팅한 끝점은 직접 움직인다 — 같은 구간 반복 쉬프트 경로."""
|
||||
assert _moving_points([40, 60], {20, 80}, 20, 80) == (20, 80)
|
||||
assert _moving_points([40, 60], {80}, 20, 80) == (40, 80)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
/* 구조물 측점과 겹치는 규칙 측점 제거 — 2026-09-06.
|
||||
TS 를 프로젝트 tsc 로 옮겨 실제 코드를 그대로 돌린다. */
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const out = mkdtempSync(join(tmpdir(), "aislo-station-"));
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
"./config/node_modules/typescript/bin/tsc",
|
||||
"B05_Profile/logic/B05_Profile_Util_Station.ts",
|
||||
"--outDir", out,
|
||||
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig",
|
||||
],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
const { dropStationsNear, STATION_MERGE_TOLERANCE_M } = await import(
|
||||
pathToFileURL(join(out, "B05_Profile_Util_Station.js")).href
|
||||
);
|
||||
|
||||
const regular = [0, 20, 40, 60, 80].map((chainage_m) => ({ chainage_m, kind: "regular" }));
|
||||
const structures = [{ chainage_m: 40.05 }, { chainage_m: 63.0 }];
|
||||
const kept = dropStationsNear(regular, structures);
|
||||
|
||||
assert.deepEqual(
|
||||
kept.map((s) => s.chainage_m),
|
||||
[0, 20, 60, 80],
|
||||
"0.1m 안에서 겹친 40m 규칙 측점만 빠져야 한다",
|
||||
);
|
||||
assert.equal(STATION_MERGE_TOLERANCE_M, 0.1);
|
||||
// 구조물이 없으면 아무것도 지우지 않는다.
|
||||
assert.equal(dropStationsNear(regular, []).length, regular.length);
|
||||
console.log("OK — 구조물 측점과 겹친 규칙 측점만 제거");
|
||||
@@ -66,7 +66,6 @@ def test_종류를_바꾸면_통째로_새_값(out):
|
||||
def test_참조_모양_검사(out):
|
||||
assert out["isItem"] == [True, False, False, False]
|
||||
assert out["readItem"] == [A_KEY, None]
|
||||
assert out["same"] == [True, False, True, False] # 이름만 바뀌면 같음
|
||||
|
||||
|
||||
def test_키는_앞에_안_내세움_툴팁에만(out):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
옮긴 관 · 구조물 측점은 B05 확정 전이면 DB 행이 없다. 저장 · 확정이 그 자리 수정을 받으면
|
||||
`ensure_structure_stations`([측점 만들기]와 같은 길)를 불러 측점을 세운 뒤 행을 만든다.
|
||||
DB · 파일 전체 왕복은 test_b05_b06_save_roundtrip [B06] 이 봄 — 여기서는 부르는 조건만.
|
||||
여기서는 부르는 조건만.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
Reference in New Issue
Block a user