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

This commit is contained in:
2026-10-16 15:03:10 +09:00
6 changed files with 609 additions and 23 deletions
+76 -8
View File
@@ -23,13 +23,18 @@ import { createDesignPipeline, publishToStore, type DesignPipeline } from "./log
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
import { createStationsStage } from "./logic/design_pipeline_stations";
import type {
FollowPatch,
PipelineInputs,
PipelineStation,
RouteDraft,
StageFn,
Vertex,
} from "./logic/design_pipeline_types";
import { useDesignStore } from "./design_store";
import { useDesignStore, type DesignStoreValues } from "./design_store";
import {
followStructures,
type FollowingStructure,
} from "../../common_util/common_util_route_follow";
import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
@@ -112,6 +117,9 @@ export interface PagePipelineOptions {
stationIntervalM?: number;
/** 노선 초안 동안 종단 · 횡단이 바뀜 — 3D 「낡음」. */
onCorridorStale?: () => void;
/** 노선 조작 뒤 follow 가 가게 관 · 구조물을 다시 씀(목록 · 알약 다시) — `far` = 새 노선에서
* `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 먼 구조물 수(초안을 버려 되돌렸으면 0). */
onFacilitiesFollowed?: (far: number) => void;
}
interface PagePipeline {
@@ -124,6 +132,9 @@ interface PagePipeline {
dispose(): void;
}
/** 가게 관 · 구조물 초안(null = 저장분). */
type Facilities = Pick<DesignStoreValues, "pipes" | "structures">;
const pages = new Map<string, PagePipeline>();
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
@@ -191,6 +202,11 @@ function createPagePipeline(
let disposed = false;
/** 열 때 · 정본이 바뀐 차례(start) 번호 — 측점 · 종단을 가게에 안 냄(상세 = 저장 정본 그대로). */
let openGeneration = -1;
/** 노선 초안 전 가게 관 · 구조물 — 초안 동안 follow 가 새 측점을 가게에 씀 · 초안을 버리면 이것으로. */
let beforeDraft: Facilities | null = null;
/** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */
let written: Facilities | null = null;
let echo = false;
const savedLine = (): Vertex[] | null =>
plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null;
@@ -231,11 +247,14 @@ function createPagePipeline(
}),
follow: createFollowStage({
savedLine,
saved: () => ({
pipes: store.get("pipes") ?? savedPipes,
structures: store.get("structures") ?? savedStructures,
alignment: savedEdits(),
}),
saved: () => {
const before = beforeDraft ?? facilitiesNow();
return {
pipes: before.pipes ?? savedPipes,
structures: before.structures ?? savedStructures,
alignment: savedEdits(),
};
},
}),
stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }),
ground: createGroundStage(groundStageDeps(projectId)),
@@ -268,6 +287,36 @@ function createPagePipeline(
const saved = event.kind === "stage" && (event.stage === "ground" || event.stage === "profile");
if (!(saved && event.generation === openGeneration)) publish(event);
});
// 사용자 노선 조작 뒤 follow 가 다시 매긴 관 · 구조물 = 가게(목록 · 알약 · 횡단 보기가 바로 새 측점) ·
// 초안을 버리면 초안 전 값 · 열 때(start 차례)는 안 씀 — 알림 밖(틱 뒤)에서 씀(쓰기 고리 막음과 안 부딪힘)
pipe.subscribe((event) => {
if (event.kind !== "stage" || event.stage !== "follow") return;
if (event.generation === openGeneration) return;
const patch = event.value as FollowPatch;
queueMicrotask(() => {
if (event.generation !== pipe.generation) return;
if (!pipe.input("route")) {
if (!beforeDraft) return;
writeFacilities(beforeDraft);
beforeDraft = null;
return options.onFacilitiesFollowed?.(0);
}
const now = facilitiesNow();
beforeDraft ??= now;
writeFacilities({
pipes: (patch.pipes as Facilities["pipes"]) ?? now.pipes,
structures: (patch.structures as Facilities["structures"]) ?? now.structures,
});
const line = pipe.output("route")?.line ?? [];
const far = Array.isArray(patch.structures)
? followStructures(
patch.structures as FollowingStructure[],
line.map(([x, y]) => ({ x, y })),
).filter((moved) => moved.far).length
: 0;
options.onFacilitiesFollowed?.(far);
});
});
pipe.subscribe((event) => {
if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) return;
const draft = pipe.input("route") !== null;
@@ -294,6 +343,22 @@ function createPagePipeline(
drawnDraft = draft;
});
function facilitiesNow(): Facilities {
return { pipes: store.get("pipes"), structures: store.get("structures") };
}
/** 가게에 씀 — 앞에 쓴 것과 같으면 안 씀(되돌리기가 세션을 되쓴 뒤에도 듣는 쪽은 다시 부름). */
function writeFacilities(next: Facilities): void {
echo = true;
try {
if (!written || !same(written.pipes, next.pipes)) store.set("pipes", next.pipes);
if (!written || !same(written.structures, next.structures))
store.set("structures", next.structures);
} finally {
echo = false;
}
written = next;
}
const inputsNow = (): PipelineInputs => {
const route = store.get("routeDraft");
return {
@@ -345,10 +410,13 @@ function createPagePipeline(
.catch(() => undefined);
const unsubscribe = [
store.subscribe("pipes", (pipes) => started && void pipe.update("pipe", pipes ?? savedPipes)),
store.subscribe(
"pipes",
(pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes),
),
store.subscribe(
"structures",
(list) => started && void pipe.update("structure", list ?? savedStructures),
(list) => started && !echo && void pipe.update("structure", list ?? savedStructures),
),
];
+35 -3
View File
@@ -66,7 +66,10 @@ import "../../B05_Profile/B05_Profile_UI_Style.css";
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { pipeIdAt } from "./design_pipe_ids";
import { movePipeId, pipeIdAt } from "./design_pipe_ids";
import { useDesignStore } from "./design_store";
import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow";
import { STRUCTURE_ROUTE_DRIFT_MAX_M } from "../../common_util/common_util_route_follow";
import {
DEFAULT_ROAD_WIDTHS,
fetchRoadWidths,
@@ -103,15 +106,43 @@ export async function mountProfileView(
});
/** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */
let lastPipes: PipeReport | null = shared.lastPipes ?? null;
const applyPipes = (pipes: PipeReport): void => {
const designStore = useDesignStore(activeProjectId, null);
/** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 id 도 옮김. */
let lastShown: PipeReport = [];
const shownPipes = (pipes: PipeReport): PipeReport => {
const byChainage = (x: PipeReport[number], y: PipeReport[number]) =>
x.chainage_m - y.chainage_m;
const shown = followPipeReports(pipes, designStore.get("pipes")).sort(byChainage);
const before = [...lastShown].sort(byChainage);
if (before.length === shown.length)
shown.forEach((pipe, i) => {
if (Math.abs(pipe.chainage_m - before[i].chainage_m) > 0.005)
movePipeId(before[i].chainage_m, pipe.chainage_m, activeProjectId);
});
lastShown = shown;
return shown;
};
const applyPipes = (reported: PipeReport, carryEdits = true): void => {
const pipes = shownPipes(reported);
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
bridge.setPipes(pipes);
bridge.setPipes(pipes, carryEdits);
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
profilePanel.setMinCoverPipes(pipes);
snapOld();
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
};
/** 노선을 놓아 계산 묶음이 가게 관 · 구조물 측점을 다시 매김(되돌림 포함) — 목록 · 알약 · 측점선. */
const followFacilities = (far: number): void => {
if (far)
showToast(
`구조물 ${far}건이 새 노선에서 ${STRUCTURE_ROUTE_DRIFT_MAX_M} m 넘게 멀어짐`,
"warning",
);
if (!host.visible()) return;
if (lastPipes) applyPipes(lastPipes, false);
bridge.takeStructures(designStore.get("structures"));
};
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
* 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
@@ -492,6 +523,7 @@ export async function mountProfileView(
draft: routeDraftBridge(activeProjectId, profilePanel, {
stationIntervalM: panel.values().stationInterval ?? undefined,
onCorridorStale: () => sections.setCorridorPending(true),
onFacilitiesFollowed: followFacilities,
}),
});
+27 -12
View File
@@ -72,8 +72,9 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
deps.profilePanel().setIrregularStations([]);
}
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송). */
function applyIrregularStations(stations: IrregularStation[]): void {
/** 비정규 측점 목록 변경 → 3D·그래프·테이블에 반영(프론트 프리뷰, 백엔드 미전송).
* `carryEdits` false = 노선이 움직여 따라간 측점(계획고 편집은 계산 묶음 follow 가 옮김 · 349-0d). */
function applyIrregularStations(stations: IrregularStation[], carryEdits = true): void {
// 위치가 바뀌거나 삭제된 비정규 측점의 옛 chainage에 남은 계획고 편집(유령 변화점)을 옮기거나 지운다.
// 옮김 = 0.006m 안 새 측점(서버 0.01m 반올림) · 같은 id · 사라진 것 하나와 새것 하나(옮긴 관 · 213-2)
// — 세션 초안도 새 키로 바뀌어 옛 키가 다시 안 나감. 나머지는 지움.
@@ -83,11 +84,12 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
(station) => !nextKeys.has(station.chainage_m.toFixed(3)),
);
const born = stations.filter((station) => !oldKeys.has(station.chainage_m.toFixed(3)));
gone.forEach((station) => {
// 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272)
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
});
if (carryEdits)
gone.forEach((station) => {
// 없으면 맞춘 자리의 새 측점 — 옛 소수 자동 맞춤이 여럿을 한꺼번에 옮겨도 편집이 따라감(234-1 · 272)
const same = followedStation(station, gone, born) ?? followSnapped(station, born);
deps.profilePanel().resetStationEdit(station.chainage_m, same?.chainage_m);
});
irregularStations = stations;
deps.renderStationLines();
deps.profilePanel().setIrregularStations(stations);
@@ -117,11 +119,12 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** 그래프 · 3D 측점 = 측점 계획 한 벌(322-3 · 카드 · B06 종단 · 서버와 같은 목록 · 같은 m 하나 · 관이 먼저).
* 종류 표를 받기 전이면 미룸(받으면 다시 맞춤). */
function syncCrossDrainStations(): void {
function syncCrossDrainStations(carryEdits = true): void {
if (!planTypes.length) return;
const interval = deps.panel().values().stationInterval || 20;
applyIrregularStations(
plannedIrregular(planStations(withPipeIds(planPipes), ownStructures, planTypes), interval),
carryEdits,
);
}
@@ -146,6 +149,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** 그래프 알약 레인에 올릴 목록 — 구조물 정본 + 계곡 통과 시설(관 정본)을 합친다.
* 자동 배수관도 세로 점선이 아니라 같은 알약으로 나온다(2026-08-17 사용자 지시 1). */
let ownStructures: StructureInstance[] = [];
/** 서버 정본 목록 — 가게 초안이 비면(노선 초안을 버림) 이것으로. */
let storedStructures: StructureInstance[] = [];
let pipeMarks: StructureInstance[] = [];
function syncGraphStructures(): void {
@@ -220,7 +225,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
writePending(null);
structureRevision = stored.revision;
deps.panel().structures.setStructures(stored.structures);
ownStructures = stored.structures;
ownStructures = storedStructures = stored.structures;
syncGraphStructures();
syncCrossDrainStations();
return true;
@@ -295,6 +300,7 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
]),
);
structureRevision = stored.revision;
storedStructures = stored.structures;
const note = staleDefinitionNotice(deps.projectId, stored.definition_version); // 322-2
if (note) showToast(note);
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
@@ -333,10 +339,10 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
irregularStations: () => irregularStations,
/** 구조물 종류의 군(A~G · 모르면 undefined) — 측점 고름이 덮어 열 구조물을 가름(216-1). */
typeGroup: (typeId: string) => structureTypeMap.get(typeId)?.group,
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다. */
setPipes(pipes: PipeProjection[]): void {
/** 관 지점 목록이 바뀜 — 측점 투영과 알약 레인을 한 번에 맞춘다(`carryEdits` = 계획고 편집 옮김). */
setPipes(pipes: PipeProjection[], carryEdits = true): void {
planPipes = [...pipes];
syncCrossDrainStations();
syncCrossDrainStations(carryEdits);
pipeMarks = pipesToMarks(pipes);
syncGraphStructures();
},
@@ -353,6 +359,15 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
markChainage,
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
applyStructures,
/** 가게 구조물 초안을 계산 묶음이 다시 씀(노선 따라 측점 다시 매김 · 되돌림 · 349-0d) — 화면만 맞춤. */
takeStructures(next: StructureInstance[] | null): void {
const list = next ?? storedStructures;
if (JSON.stringify(list) === JSON.stringify(ownStructures)) return;
ownStructures = list;
deps.panel().structures.setStructures(ownStructures);
syncGraphStructures();
syncCrossDrainStations(false);
},
saveStructuresIfDirty,
/** 서버 정본을 다시 받아 화면을 그 상태로 맞춘다. */
refreshFromServer: refreshStructuresFromServer,
@@ -0,0 +1,36 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Pipeline_Follow.ts
* 노선이 움직인 뒤 관 목록 화면 값(PLAN 349-0d) — 배수유역도가 알린 관 목록(옛 누가거리)을 가게 관 초안
* (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 누가거리 차례(따라감은 좌표 투영이라
* 차례가 거의 그대로) · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침).
* 순수 계산(DOM · fetch · 세션 없음).
* ========================================================================== */
interface Span {
chainage_m: number;
start_m?: number | null;
end_m?: number | null;
}
/** 관 알림 목록의 측점 · 구간을 가게 관 초안 쪽으로 — 초안이 없으면(저장분) 그대로. */
export function followPipeReports<R extends Span>(
reports: ReadonlyArray<R>,
pipes: ReadonlyArray<Span> | null,
): R[] {
if (!pipes || pipes.length !== reports.length) return [...reports];
const byChainage = (a: Span, b: Span): number => a.chainage_m - b.chainage_m;
const to = [...pipes].sort(byChainage);
return [...reports].sort(byChainage).map((report, index) => {
const pipe = to[index];
const chainage = Math.round(pipe.chainage_m * 100) / 100; // 알림 목록 자릿수(`buildPipeReports`)
const shift = chainage - report.chainage_m;
const moved = (own: number | null | undefined, from: number | null | undefined) =>
own ?? (from == null ? from : from + shift);
return {
...report,
chainage_m: chainage,
...(report.start_m != null ? { start_m: moved(pipe.start_m, report.start_m) } : {}),
...(report.end_m != null ? { end_m: moved(pipe.end_m, report.end_m) } : {}),
};
});
}
@@ -0,0 +1,233 @@
/* PLAN 349-0d sub7 헬퍼 — 노선을 놓으면 follow 가 다시 매긴 관 · 구조물이 가게(design_store)에 바로 씀 ·
* 묶음 한 번 · 사진첩 한 걸음 안(놓은 뒤 setTimeout 0 전에 씀) · 되돌리기 · 초안 버림 = 초안 전 값 · 열기만 = 안 씀 ·
* 새 노선에서 먼 구조물 수. 진짜 TS 를 트랜스파일해 Node 로 · 서버 창구만 가짜(`helper_349_pipeline_once` 와 같은 틀).
* 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_349_0d_follow_store.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
}
globalThis.window = {
sessionStorage: memoryStorage(),
localStorage: memoryStorage(),
setTimeout,
clearTimeout,
};
// 흙 배분 서버 — 늦게 답함 · 끊기면 버림
globalThis.fetch = (url, init) =>
new Promise((resolve, reject) => {
const timer = setTimeout(
() => resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }),
20,
);
init.signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(new Error("aborted"));
});
});
const STUBS = {};
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 (CATALOG.isCatalogRequest(request)) return CATALOG;
const key = Object.keys(STUBS).find((name) => request.endsWith(name));
if (key) return STUBS[key];
if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" };
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 load = (...parts) => loadTs(path.join(ROOT, ...parts));
const sampler = load("common_util", "common_util_surface_sampler.ts");
const bytes = fs.readFileSync(process.argv[3]);
const grid = sampler.parseDtmGrid(
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length),
);
const local = { grid, design: "las" };
STUBS["B05_Profile_Api_Replan"] = {
analysisTerrain: () => "las",
fetchRoutePlan: async () => ({
planned: job.line,
curves: job.curves,
min_radius_m: job.minRadius,
station_snap: job.stationSnap,
}),
};
STUBS["B05_Profile_Api_Structures"] = {
fetchStructures: async () => ({ structures: job.structures }),
fetchStructureTypes: async () => job.types,
};
STUBS["B05_Profile_Api_TerrainGrid"] = {
loadDesignTerrainGrid: async () => local,
groundStageDeps: () => ({ grid: () => local, remote: async () => [] }),
};
STUBS["B04_PreProcess_Api_Fetch"] = {
fetchDetailPipePoints: async () => ({
pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })),
}),
};
STUBS["B06_Section_UI_Standard_Panel"] = { effectiveStandardCross: () => job.standard };
const pageMod = load("A00_Common", "design", "design_pipeline_page.ts");
const storeMod = load("A00_Common", "design", "design_store.ts");
const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts");
const page = load("A00_Common", "b_page_state.ts");
const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms));
const runs = () => globalThis.__aisloPipeline?.runs ?? [];
/** 묶음이 조용해질 때까지 — 마지막 기록이 끝나고 새 기록이 더 안 생김(고리면 계속 생김). */
async function quiet() {
let seen = -1;
for (let i = 0; i < 400; i += 1) {
await tick(15);
const last = runs()[runs().length - 1];
const count = runs().length;
if (count === seen && (!last || last.outcome !== "running")) return;
seen = count;
}
throw new Error("묶음이 조용해지지 않음");
}
const n = job.nodes.length;
const draftOf = (nodes) => ({
nodes,
curveOn: Array(n).fill(true),
curveRadius: Array(n).fill(null),
curveLock: Array(n).fill(null),
curveArc: Array(n).fill(null),
stationSnap: job.stationSnap,
});
const shift = (index, dx, dy) =>
job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node));
const chainagesOf = (stations) => (stations ?? []).map((s) => s.chainageM);
const P = "proj-3490d";
const ids = (list) => (list ?? []).map((item) => [item.id ?? item.structure_id, item.chainage_m]);
async function run() {
window.localStorage.setItem("frd_current_project_id", P);
const savedAlignment = job.detail.longitudinal.profile_alignment;
const savedBase = alignment.toAlignmentBase(savedAlignment);
const followed = [];
const link = {
pipelineSource: () => ({
base: savedBase,
edits: savedAlignment.edits,
detail: job.detail,
routeId: 7,
}),
adoptPipeline: (base, edits, draft) =>
pageMod.pipelineGrade(P, draft ? edits : savedAlignment.edits),
redraw: () => undefined,
side: null,
};
const store = storeMod.useDesignStore(P, null);
const bridge = pageMod.routeDraftBridge(P, link, {
stationIntervalM: job.interval,
onFacilitiesFollowed: (far) => followed.push(far),
});
pageMod.pipelineEarthwork(P, { conversion: job.conversion });
await quiet();
const pipe = pageMod.pagePipeline(P);
const open = {
storePipes: store.get("pipes"),
storeStructures: store.get("structures"),
followed: [...followed],
};
// 사진첩 흉내 — 놓기 전 세션 사진
const snapshot = {};
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
snapshot[key] = window.sessionStorage.getItem(key);
}
// 꼭짓점 한 번 끌어 놓기 — 사진첩은 놓은 뒤 setTimeout 0 에 한 걸음 찍음
let mark = runs().length;
bridge.edited(draftOf(shift(1, 9, -6)));
await tick(0);
const atRecord = { pipes: ids(store.get("pipes")), structures: ids(store.get("structures")) };
await quiet();
const drag = {
runs: runs()
.slice(mark)
.map((r) => r.sources),
atRecord,
pipes: ids(store.get("pipes")),
structures: ids(store.get("structures")),
inputPipes: ids(pipe.input("pipes")),
followed: [...followed],
blocked: pipe.blockedWrites,
};
// 되돌리기 — 사진첩이 세션을 되쓴 뒤 노선 편집기가 초안 없음으로 알림
for (const key of Object.keys(snapshot)) window.sessionStorage.setItem(key, snapshot[key]);
for (let i = window.sessionStorage.length - 1; i >= 0; i -= 1) {
const key = window.sessionStorage.key(i);
if (!(key in snapshot)) window.sessionStorage.removeItem(key);
}
mark = runs().length;
bridge.edited(null);
await quiet();
const undo = {
runs: runs()
.slice(mark)
.map((r) => r.sources),
storePipes: store.get("pipes"),
storeStructures: store.get("structures"),
inputPipes: ids(pipe.input("pipes")),
followed: [...followed],
};
// 다시 끌고 사진첩 없이 초안만 버림(편집기 [버림] 등) — 가게 = 초안 전 값
bridge.edited(draftOf(shift(1, 9, -6)));
await quiet();
const second = ids(store.get("pipes"));
bridge.edited(null);
await quiet();
const drop = {
second,
storePipes: store.get("pipes"),
inputPipes: ids(pipe.input("pipes")),
blocked: pipe.blockedWrites,
};
pageMod.disposePagePipeline(P);
return { open, drag, undo, drop, saved: ids(job.pipes), savedStructures: ids(job.structures) };
}
run()
.then((out) => {
process.stdout.write(JSON.stringify(out));
process.exit(0);
})
.catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
@@ -0,0 +1,202 @@
"""PLAN 349-0d sub7 — 노선을 놓으면 관 · 구조물 새 측점이 가게에 바로.
· 열기만 = 가게 안 씀(저장값 그대로)
· 꼭짓점 한 번 끌기 = 묶음 한 번 · 가게 관 · 구조물 측점 바뀜
· 사진첩이 찍기 전(setTimeout 0)에 씀(한 걸음)
· 새 노선에서 20 m 넘게 먼 구조물 수 알림
· 되돌리기(사진첩이 세션을 되씀) · 초안만 버림 = 초안 전 값(가게 비움 · 입력 = 저장 관)
· 계산 · 알림 안 입력 쓰기 0
짝: `helper_349_0d_follow_store.cjs`(실제 TS 를 Node 로 · 서버 창구만 가짜 · 검증_sub7 노선 · 관).
"""
from __future__ import annotations
import csv
import json
import math
import subprocess
import sys
import tempfile
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map # noqa: E402
from common_util.common_util_surface_sampler import dtm_grid_bytes # noqa: E402
from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402
pytestmark = pytest.mark.skipif(
not (ROOT / "config" / "node_modules" / "typescript").is_dir(),
reason="프론트엔드 의존성 미설치",
)
PROJECT = ROOT / "storage/1/3/6d3b2812-6d5a-4168-a932-40e4ae303d5a" # 검증_sub7
ROUTE = PROJECT / "B05_Profile/route"
SECTION = PROJECT / "B06_Section"
GRID = PROJECT / "B04_PreProcess/models/dtm_classification_smooth.npz"
ONES = {g: {"loose": 1.0, "compacted": 1.0} for g in ("soil", "ripping_rock", "blasting_rock")}
def _csv(path: Path) -> list[list[float]]:
with path.open(encoding="utf-8") as handle:
return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(handle)]
def _json(path: Path):
return json.loads(path.read_text(encoding="utf-8"))
def _structure(sid: str, line: list[list[float]], ratio: float, off: float) -> dict:
"""노선 위(off 0) · 옆(off m) 점 구조물 — 저장 노선 누가거리 · 좌표."""
lengths = [math.dist(a, b) for a, b in zip(line, line[1:])]
at = sum(lengths) * ratio
walked = 0.0
for (a, b), length in zip(zip(line, line[1:]), lengths):
if walked + length >= at:
t = (at - walked) / length
dx, dy = (b[0] - a[0]) / length, (b[1] - a[1]) / length
x, y = a[0] + dx * length * t - dy * off, a[1] + dy * length * t + dx * off
break
walked += length
return {
"structure_id": sid,
"type_id": "erosion_check",
"placement": "point",
"chainage_m": round(at, 2),
"start_m": None,
"end_m": None,
"x": x,
"y": y,
"options": {},
}
@pytest.fixture(scope="module")
def r() -> dict:
if not (GRID.is_file() and (SECTION / "longitudinal/longitudinal.json").is_file()):
pytest.skip("검증_sub7 없음")
curves = _json(ROUTE / "planned_route_curves.json")
sections = sorted(
(_json(p) for p in (SECTION / "cross_sections").glob("cross_*.json")),
key=lambda s: s["chainage_m"],
)
pipes = _json(PROJECT / "B04_PreProcess/drainage/edits/pipe_points.json")["points"]
line = _csv(ROUTE / "planned_route.csv")
structures = [_structure("s-near", line, 0.5, 0.0), _structure("s-far", line, 0.7, 30.0)]
job = {
"line": line,
"structures": structures,
"nodes": _csv(ROUTE / "planned_route_nodes.csv"),
"curves": [
{k: c[k] for k in ("start", "apex", "end", "radius_m")} for c in curves["curves"]
],
"stationSnap": bool(curves.get("station_snap")),
"minRadius": curves["min_radius_m"],
"interval": 20.0,
"types": [
{"type_id": t.type_id, "group": t.group, "placement": t.placement}
for t in structure_type_map().values()
],
"pipes": pipes,
"standard": STANDARD_CROSS_SECTION,
"conversion": ONES,
"detail": {
"longitudinal": _json(SECTION / "longitudinal/longitudinal.json"),
"cross_sections": sections,
},
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
grid = Path(folder) / "grid.bin"
grid.write_bytes(dtm_grid_bytes(GRID))
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_349_0d_follow_store.cjs"), str(path), str(grid)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_열기만_하면_가게_안_씀(r):
o = r["open"]
assert o["storePipes"] is None and o["storeStructures"] is None
assert o["followed"] == []
def test_꼭짓점_끌기_가게에_새_측점_한_번(r):
d = r["drag"]
assert d["runs"] == [["route"]], d["runs"]
assert d["pipes"] == d["inputPipes"]
assert d["pipes"] != r["saved"]
assert len(d["pipes"]) == len(r["saved"]) # 같은 관 그대로(자리만 다시 매김)
assert {i: c for i, c in d["structures"]} != {i: c for i, c in r["savedStructures"]}
assert d["atRecord"] == {"pipes": d["pipes"], "structures": d["structures"]} # 한 걸음 안
assert d["followed"] == [1] # s-far 만 20 m 넘음
assert d["blocked"] == 0
def test_되돌리기_초안_전_값(r):
u = r["undo"]
assert u["runs"] == [["route"]], u["runs"]
assert u["storePipes"] is None and u["storeStructures"] is None
assert u["inputPipes"] == r["saved"]
assert u["followed"] == [1, 0]
def test_초안만_버림_초안_전_값(r):
d = r["drop"]
assert d["second"] != r["saved"]
assert d["storePipes"] is None
assert d["inputPipes"] == r["saved"]
assert d["blocked"] == 0
_REPORTS_JS = r"""
const fs = require("fs");
const ts = require(process.argv[1] + "/config/node_modules/typescript");
const file = process.argv[1] + "/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts";
const src = fs.readFileSync(file, "utf8");
const options = { compilerOptions: { module: ts.ModuleKind.CommonJS } };
const js = ts.transpileModule(src, options).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const f = box.exports.followPipeReports;
const reports = [
{ chainage_m: 86, effective_diameter_mm: 800, start_m: 84, end_m: 88 },
{ chainage_m: 175, effective_diameter_mm: 1000 },
];
const pipes = [{ chainage_m: 179.374 }, { chainage_m: 89.123, start_m: 87.5 }];
process.stdout.write(JSON.stringify({
moved: f(reports, pipes),
saved: f(reports, null),
count: f(reports, pipes.slice(1)),
}));
"""
def test_관_알림_목록_가게_측점으로():
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", "-e", _REPORTS_JS, str(ROOT)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
first, second = out["moved"]
assert (first["chainage_m"], first["effective_diameter_mm"]) == (89.12, 800)
assert first["start_m"] == 87.5 # 가게 구간 그대로
assert abs(first["end_m"] - 91.12) < 1e-9 # 없으면 옮긴 만큼
assert (second["chainage_m"], second["effective_diameter_mm"]) == (179.37, 1000)
assert [p["chainage_m"] for p in out["saved"]] == [86, 175]
assert [p["chainage_m"] for p in out["count"]] == [86, 175] # 관 수 다르면 그대로