feat(B05 종단): 원지반 점 작은 창 = 지금 계획선(초안 포함 · 묶음 같은 계산) (368-4)
- 묶음 출력 읽기만 · ground · cross 단계 함수를 그 자리 측점 하나로 다시 부름(파생 저장 · 캐시 안 씀) - 그 자리 측점 = [측점 추가] 뒤 묶음 측점과 같은 틀 · 반경 · 확폭(logic/design_pipeline_probe_station) - 격자 없음 · 분석 지형 다름 = 서버 미리 뜨기 + 「저장 계획선 기준」 한 줄 · 창 글에 계획고 - 묶음 cross 차례마다 떠 있는 창 다시 그림 - 거울 시험 test_368_4(곡선표 있음 · 없음 · 직선 · 곡선 안) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user