Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1

This commit is contained in:
2026-10-28 21:10:22 +09:00
35 changed files with 248 additions and 1446 deletions
+27 -54
View File
@@ -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);
}
+1 -7
View File
@@ -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;
}
+3 -15
View File
@@ -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));
},
};
}
+2 -484
View File
@@ -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)),
+13 -3
View File
@@ -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` 이다 — 서버와 같이 기본형으로 되돌린다.
+5 -1
View File
@@ -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 {
+3 -2
View File
@@ -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) — 부를 때마다 읽음(되돌리기 · 다른 보기가 바꿔도 같은 값). */
-6
View File
@@ -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:
+1 -3
View File
@@ -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)]
+1 -1
View File
@@ -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):
+1 -1
View File
@@ -2,7 +2,7 @@
옮긴 관 · 구조물 측점은 B05 확정 전이면 DB 행이 없다. 저장 · 확정이 그 자리 수정을 받으면
`ensure_structure_stations`([측점 만들기]와 같은 길)를 불러 측점을 세운 뒤 행을 만든다.
DB · 파일 전체 왕복은 test_b05_b06_save_roundtrip [B06] 이 봄 — 여기서는 부르는 조건만.
여기서는 부르는 조건만.
"""
import asyncio