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

This commit is contained in:
2026-10-17 13:22:00 +09:00
6 changed files with 362 additions and 92 deletions
@@ -0,0 +1,94 @@
/* =============================================================================
* A00_Common/design/design_pipeline_probe.ts
* 계산 묶음 아무 누가거리 한 장(368-4 · 요구 240-21) — 종단 원지반 점 작은 창 = 지금 계획선(초안 포함).
*
* 묶음 출력(노선 · 측점 · 종단 · 횡단 입력 · 관 · 구조물)을 읽기만 하고, 같은 단계 함수(ground · cross ·
* `crossWithUphill` · `stationSection`)를 그 자리 측점 하나로 다시 부름 — [측점 추가] 뒤 묶음이 낼 측점과
* 같은 틀 · 반경 · 확폭(기존 측점 + 그 자리로 셈). 파생 저장 · 캐시 · 묶음 상태는 안 건드림.
* 지형 격자가 없거나 분석 지형 ≠ 설계 지형이면 null(부르는 쪽이 서버 미리 뜨기로).
* ========================================================================== */
import { pagePipeline } from "./design_pipeline_page";
import type {
CrossOutput,
GroundOutput,
StageContext,
StageName,
} from "./logic/design_pipeline_types";
import { probeStation } from "./logic/design_pipeline_probe_station";
import { groundStageDeps } from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
import { createGroundStage } from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
import {
crossWithUphill,
type CrossInput,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
import { pipelineCrossAt } from "../../B05_Profile/B05_Profile_UI_RouteEdit_Cross";
import type { CrossPreviewResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
const SAME_M = 1e-6;
/**
* 지금 계획선(초안 포함)으로 그 누가거리 임시 횡단 — 묶음이 아직 안 셌거나 격자 없음 · 분석 지형 ≠ 설계 지형이면 null.
*/
export async function pipelineCrossAtChainage(
projectId: string,
chainageM: number,
intervalM: number,
): Promise<CrossPreviewResponse | null> {
const pipe = pagePipeline(projectId);
const route = pipe?.output("route");
const profile = pipe?.output("profile");
const ground = pipe?.output("ground");
if (!pipe || !route || !profile || !ground) return null;
const deps = groundStageDeps(projectId);
const local = deps.grid();
const terrain = pipe.input("route")?.terrain ?? null;
if (!local || (terrain !== null && terrain !== local.design)) return null;
const inputs = {
route: pipe.input("route"),
alignment: pipe.input("alignment"),
cross: pipe.input("cross"),
pipes: pipe.input("pipes"),
structures: pipe.input("structures"),
};
const existing = ground.stations.find((s) => Math.abs(s.chainageM - chainageM) < SAME_M);
let station = existing ?? null;
if (!station) {
const probe = probeStation(route.line, route.curves, ground.stations, chainageM);
if (!probe) return null;
// ground 단계 그대로 — 측점 하나 · 종단 지반선은 안 뜸(노선 선 빼고 넘김)
const ctx = {
generation: 0,
range: null,
signal: new AbortController().signal,
inputs,
out: { stations: [probe], route: { ...route, line: null } },
before: {},
prev: null,
} as unknown as StageContext<"ground">;
const filled = (await createGroundStage(deps)(ctx)) as GroundOutput;
station = filled.stations[0] ?? null;
}
if (!station) return null;
const out = {
profile,
ground: { stations: [station], longitudinal: { chainageM: [], z: [] } },
} as Partial<Record<StageName, unknown>>;
const cross: CrossOutput = createCrossStage({ grid: deps.grid }).stage({
generation: 0,
range: null,
signal: new AbortController().signal,
inputs: { ...inputs, cross: crossWithUphill(inputs.cross as CrossInput | null, [station]) },
out,
before: {},
prev: null,
} as unknown as StageContext<"cross">);
// 평면 우측 횡단 판과 같은 꼴(그림 · 요약) — 이 측점 하나만 실은 묶음처럼 넘김
const one = {
output: (name: StageName) =>
name === "ground" ? { stations: [station] } : name === "cross" ? cross : null,
} as unknown as Parameters<typeof pipelineCrossAt>[0];
return pipelineCrossAt(one, chainageM, intervalM, undefined);
}
@@ -0,0 +1,65 @@
/* =============================================================================
* A00_Common/design/logic/design_pipeline_probe_station.ts
* 아무 누가거리 측점 하나(368-4) — [측점 추가] 뒤 묶음 stations 단계(`buildStations`)가 낼 그 측점과 같은
* 자리 · 틀 · 방위 · 반경 · 확폭(기존 측점 + 그 자리 누가거리로 셈) · 열쇠 = 추가 측점(`structure:plus-{m}`).
* 거울 시험 = `resources/tester/test_368_4_probe_station.py`(buildStations + 추가 측점과 대조).
* ========================================================================== */
import { stationFrameAt } from "../../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame";
import {
curveWidenings,
designCurveWidenings,
designSpans,
planRadii,
stationKey,
} from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
import { PLUS_OWNER_PREFIX } from "../../../common_util/common_util_structure_station";
import type { DesignCurve, PipelineStation, Vertex } from "./design_pipeline_types";
const round = (value: number, digits: number): number => Number(value.toFixed(digits));
/** 그 자리 측점(지반 전) — 노선 밖이면 null. `stations` = 지금 묶음 측점(반경 · 확폭 테이퍼 이웃). */
export function probeStation(
line: ReadonlyArray<Vertex>,
curves: ReadonlyArray<DesignCurve>,
stations: ReadonlyArray<PipelineStation>,
chainageM: number,
): PipelineStation | null {
const frame = stationFrameAt(line, chainageM);
if (!frame) return null;
const chainages = [...stations.map((s) => s.chainageM), chainageM].sort((a, b) => a - b);
const index = chainages.indexOf(chainageM);
const spans = designSpans(line, curves);
let radius: number | null;
let side: "left" | "right" | null;
let widening: number;
if (spans.length) {
const out = designCurveWidenings(chainages, spans);
[radius, side, widening] = [out.radii[index], out.sides[index], out.widenings[index]];
} else {
const total = Math.max(...stations.map((s) => s.chainageM), chainageM);
const measured = planRadii(line, chainages, total);
const tapered = curveWidenings(chainages, measured.radii, measured.sides);
[radius, side, widening] = [
measured.radii[index],
tapered.sides[index],
tapered.widenings[index],
];
}
const tangent: Vertex = [frame.left[1], -frame.left[0]];
const owner = `${PLUS_OWNER_PREFIX}${Math.round(chainageM)}`;
return {
key: stationKey("structure", owner, "anchor"),
chainageM: round(chainageM, 6),
x: round(frame.origin[0], 6),
y: round(frame.origin[1], 6),
azimuthDeg: round(((Math.atan2(tangent[0], tangent[1]) * 180) / Math.PI + 360) % 360, 6),
left: [round(frame.left[0], 9), round(frame.left[1], 9)],
kind: "structure",
id: owner,
ground: null,
planRadiusM: radius,
curveOuterSide: side,
curveWideningM: widening,
};
}
@@ -3,8 +3,9 @@
* 종단 원지반선 점 → 그 자리 임시 횡단 작은 창(368-3 · 요구 240-21).
*
* 열기 = 도구가 꺼진 때 원지반선 근처 누름(정수 m) · 추가 측점 세로선 누름. 창은 종단 칸 안에 겹침
* (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 서버 미리 뜬 횡단(`preview-stations` ·
* 저장 계획선 기준 · 파일에 안 씀) + 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동.
* (유토 배지처럼 · [창 분할]에 따라감 · 다시 그려도 남음). 내용 = 지금 계획선(초안 포함 · 묶음 같은 계산
* `pipelineCrossAtChainage` · 368-4) — 격자 없음 · 분석 지형 다름이면 서버 미리 뜬 횡단(`preview-stations` ·
* 저장 계획선 기준 · 글 한 줄) · 평면 우측 횡단과 같은 그림(`buildCrossSvg`) · 확대 · 이동 · 묶음 cross 차례마다 다시.
* [측점 추가] = 추가 측점 초안(일반측점 · [저장] 한 번) · 같은 m 에 측점이 있으면 막음 · [삭제] = 추가
* 측점일 때만 · [닫기].
* ========================================================================== */
@@ -19,6 +20,8 @@ import {
} from "../A00_Common/design/logic/design_profile_free";
import { Y_WINDOW_ATTRS, Y_WINDOW_CLASS } from "../B06_Section/B06_Section_UI_Longitudinal";
import { previewStructureSections } from "../B06_Section/B06_Section_Api_Fetch";
import { pagePipeline } from "../A00_Common/design/design_pipeline_page";
import { pipelineCrossAtChainage } from "../A00_Common/design/design_pipeline_probe";
import { stationLabel } from "../common_util/common_util_station";
import { plusStationsOf, writePlusDraft } from "./B05_Profile_Api_PlusStations";
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
@@ -134,7 +137,11 @@ function createCrossModal(host: HTMLElement): CrossModal {
box.replaceChildren();
foot.textContent = "횡단을 뜨는 중…";
try {
const [cross] = await previewStructureSections(projectId(), [m]);
// 지금 계획선(초안 포함 · 묶음 같은 계산) — 격자 없음 · 분석 지형 다름이면 서버(저장 계획선)
const now = await pipelineCrossAtChainage(projectId(), m, current?.intervalM ?? 20).catch(
() => null,
);
const [cross] = now ? [now] : await previewStructureSections(projectId(), [m]);
if (mine !== turn) return;
if (!cross) {
foot.textContent = "이 자리의 횡단을 뜨지 못했습니다.";
@@ -157,13 +164,38 @@ function createCrossModal(host: HTMLElement): CrossModal {
content: plot.content,
wheelZoom: true,
});
foot.textContent = `${summarizeCross(preview)} · 저장된 계획선 기준`;
const planZ = Number(
(preview.design as { design_elevation_m?: unknown } | null)?.design_elevation_m,
);
root.dataset.planZ = Number.isFinite(planZ) ? planZ.toFixed(3) : "";
foot.textContent = [
Number.isFinite(planZ) ? `계획고 ${planZ.toFixed(2)} m` : "",
summarizeCross(preview),
now ? "" : "저장 계획선 기준",
]
.filter(Boolean)
.join(" · ");
} catch (error) {
if (mine === turn)
foot.textContent = error instanceof Error ? error.message : "횡단을 뜨지 못했습니다.";
}
}
// 계획고 · 노선이 바뀌면(묶음 cross 차례) 떠 있는 창을 다시 그림
let watched: unknown = null;
let stopPipe = (): void => undefined;
function watchPipe(): void {
const pipe = pagePipeline(projectId());
if (pipe === watched) return;
stopPipe();
watched = pipe;
stopPipe =
pipe?.subscribe((event) => {
if (event.kind === "stage" && event.stage === "cross" && current)
void draw(current.chainageM);
}, "종단 작은 창") ?? ((): void => undefined);
}
function close(): void {
root.hidden = true;
current = null;
@@ -192,6 +224,7 @@ function createCrossModal(host: HTMLElement): CrossModal {
station.textContent = stationLabel(request.chainageM, request.intervalM);
root.hidden = false;
syncButtons();
watchPipe();
void draw(request.chainageM);
},
};
+62 -88
View File
@@ -361,11 +361,11 @@
"model3d": [],
"band": 0,
"station_py": [
"파형강관 D800@101.00"
"파형강관 D1000@101.00"
],
"cards_py": {
"101.00": {
"title": "파형강관 D800",
"title": "파형강관 D1000",
"shape": {}
}
}
@@ -377,7 +377,7 @@
"cards": {
"101.00": {
"title": "흄관 D800",
"display": "흄관 D800",
"display": "배수관",
"shape": {
"culvert": {
"hidden_pipe": false,
@@ -480,6 +480,13 @@
"anchor@101.00"
],
"cards": {
"100.00": {
"title": null,
"display": null,
"shape": {
"ford_pavement": true
}
},
"101.00": {
"title": "물넘이포장",
"display": "물넘이포장",
@@ -514,6 +521,13 @@
"anchor@101.00"
],
"cards": {
"100.00": {
"title": null,
"display": null,
"shape": {
"ford_pavement": true
}
},
"101.00": {
"title": "물넘이포장",
"display": "물넘이포장",
@@ -622,100 +636,40 @@
}
},
"cross_drain_exposed/main": {
"station": [
"anchor@101.00"
],
"cards": {
"101.00": {
"title": "노출형 횡단수로",
"display": "노출형 횡단수로",
"shape": {}
}
},
"station": [],
"cards": {},
"weep": false,
"model3d": [],
"band": 0,
"station_py": [
"노출형 횡단수로@101.00"
],
"cards_py": {
"101.00": {
"title": "노출형 횡단수로",
"shape": {}
}
}
"station_py": [],
"cards_py": {}
},
"cross_drain_exposed/blank": {
"station": [
"anchor@101.00"
],
"cards": {
"101.00": {
"title": "노출형 횡단수로",
"display": "노출형 횡단수로",
"shape": {}
}
},
"station": [],
"cards": {},
"weep": false,
"model3d": [],
"band": 0,
"station_py": [
"노출형 횡단수로@101.00"
],
"cards_py": {
"101.00": {
"title": "노출형 횡단수로",
"shape": {}
}
}
"station_py": [],
"cards_py": {}
},
"open_ditch/main": {
"station": [
"anchor@101.00"
],
"cards": {
"101.00": {
"title": "개거(겉도랑)",
"display": "개거(겉도랑)",
"shape": {}
}
},
"station": [],
"cards": {},
"weep": false,
"model3d": [],
"band": 0,
"station_py": [
"개거(겉도랑)@101.00"
],
"cards_py": {
"101.00": {
"title": "개거(겉도랑)",
"shape": {}
}
}
"station_py": [],
"cards_py": {}
},
"open_ditch/blank": {
"station": [
"anchor@101.00"
],
"cards": {
"101.00": {
"title": "개거(겉도랑)",
"display": "개거(겉도랑)",
"shape": {}
}
},
"station": [],
"cards": {},
"weep": false,
"model3d": [],
"band": 0,
"station_py": [
"개거(겉도랑)@101.00"
],
"cards_py": {
"101.00": {
"title": "개거(겉도랑)",
"shape": {}
}
}
"station_py": [],
"cards_py": {}
},
"cross_underdrain/main": {
"station": [
@@ -2451,7 +2405,17 @@
},
"gravel_surfacing/main": {
"station": [],
"cards": {},
"cards": {
"100.00": {
"title": null,
"display": null,
"shape": {
"pavement": [
"gravel_surfacing"
]
}
}
},
"weep": false,
"model3d": [],
"band": 1,
@@ -2469,7 +2433,17 @@
},
"gravel_surfacing/blank": {
"station": [],
"cards": {},
"cards": {
"100.00": {
"title": null,
"display": null,
"shape": {
"pavement": [
"gravel_surfacing"
]
}
}
},
"weep": false,
"model3d": [],
"band": 1,
@@ -2602,16 +2576,16 @@
"b06": "shown"
},
"refuge": {
"b05": "absent",
"b06": "absent"
"b05": "hidden",
"b06": "hidden"
},
"work_yard": {
"b05": "absent",
"b06": "absent"
"b05": "hidden",
"b06": "hidden"
},
"turnaround": {
"b05": "absent",
"b06": "absent"
"b05": "hidden",
"b06": "hidden"
},
"mirror": {
"b05": "shown",
@@ -2650,7 +2624,7 @@
"b06": "shown"
},
"borrow_pit": {
"b05": "shown",
"b05": "hidden",
"b06": "shown"
},
"wildlife_path": {
@@ -0,0 +1,50 @@
/* 368-4 아무 누가거리 측점 하나 거울 — 진짜 `design_pipeline_probe_station.ts`(probeStation) 와
* `B05_Profile_Sections_Stations.ts`(buildStations + 추가 측점)를 Node 로 돌려 같은 측점인지 냄.
* 입력 argv[2] JSON {line, curves, pipes, structures, types, at: [m...]} · 짝 = test_368_4_probe_station.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 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;
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 logic = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Sections_Stations.ts"));
const station = loadTs(path.join(ROOT, "common_util", "common_util_structure_station.ts"));
const probe = loadTs(
path.join(ROOT, "A00_Common", "design", "logic", "design_pipeline_probe_station.ts"),
);
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const build = (curves) =>
logic.buildStations(job.line, curves, job.pipes, job.structures, {
types: job.types,
stationSnap: false,
});
const out = [];
for (const curves of [job.curves, []]) {
for (const at of job.at) {
station.setPlusStations(null);
const before = build(curves);
const mine = probe.probeStation(job.line, curves, before, at);
station.setPlusStations(() => [at]);
const after = build(curves).find((s) => s.key === mine.key) ?? null;
out.push({ curves: curves.length, at, mine, after });
}
}
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,54 @@
"""368-4 원지반 점 작은 창 = 지금 계획선 — 아무 누가거리 측점 하나(`probeStation`)가
[측점 추가] 뒤 묶음 측점(`buildStations` + 추가 측점)과 같은 자리 · 틀 · 방위 · 반경 · 확폭 ·
열쇠(곡선표 있음 · 없음 · 직선 · 곡선 안).
TS 쪽 = `helper_368_4_probe_station.cjs`(Node). 노선 · 관 · 구조물은 344-3 거울 시험 것을 그대로 씀.
"""
from __future__ import annotations
import json
import subprocess
import tempfile
from pathlib import Path
import pytest
import test_344_3_pipeline_stations_mirror as base
HERE = Path(__file__).resolve().parent
pytestmark = base.pytestmark
@pytest.fixture(scope="module")
def out() -> list[dict]:
job = {
"line": base.LINE,
"curves": base.CURVES,
"pipes": base.PIPES,
"structures": base.STRUCTURES,
"types": base._types(),
"at": [77.0, 205.0, 213.0], # 직선 · 곡선 안 · 곡선 끝 테이퍼
}
with tempfile.TemporaryDirectory() as folder:
path = Path(folder) / "job.json"
path.write_text(json.dumps(job), encoding="utf-8")
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_368_4_probe_station.cjs"), str(path)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_작은_창_측점_묶음_추가_측점과_같음(out) -> None:
assert len(out) == 6
for case in out:
assert case["after"] is not None, case
assert case["mine"] == case["after"], case
# 곡선 안 측점은 반경 · 확폭이 실제로 섬(빈 값만 대조하지 않게)
assert any(case["mine"]["planRadiusM"] for case in out)
assert any(case["mine"]["curveWideningM"] > 0 for case in out)