From 2454bd4ba097218dabb50949c23038f7688568f7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 13:05:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EC=A2=85=EB=8B=A8):=20=EC=9B=90?= =?UTF-8?q?=EC=A7=80=EB=B0=98=20=EC=A0=90=20=EC=9E=91=EC=9D=80=20=EC=B0=BD?= =?UTF-8?q?=20=3D=20=EC=A7=80=EA=B8=88=20=EA=B3=84=ED=9A=8D=EC=84=A0(?= =?UTF-8?q?=EC=B4=88=EC=95=88=20=ED=8F=AC=ED=95=A8=20=C2=B7=20=EB=AC=B6?= =?UTF-8?q?=EC=9D=8C=20=EA=B0=99=EC=9D=80=20=EA=B3=84=EC=82=B0)=20(368-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 묶음 출력 읽기만 · ground · cross 단계 함수를 그 자리 측점 하나로 다시 부름(파생 저장 · 캐시 안 씀) - 그 자리 측점 = [측점 추가] 뒤 묶음 측점과 같은 틀 · 반경 · 확폭(logic/design_pipeline_probe_station) - 격자 없음 · 분석 지형 다름 = 서버 미리 뜨기 + 「저장 계획선 기준」 한 줄 · 창 글에 계획고 - 묶음 cross 차례마다 떠 있는 창 다시 그림 - 거울 시험 test_368_4(곡선표 있음 · 없음 · 직선 · 곡선 안) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- A00_Common/design/design_pipeline_probe.ts | 94 +++++++++++++++++++ .../logic/design_pipeline_probe_station.ts | 65 +++++++++++++ .../B05_Profile_UI_Profile_CrossModal.ts | 41 +++++++- .../tester/helper_368_4_probe_station.cjs | 50 ++++++++++ resources/tester/test_368_4_probe_station.py | 54 +++++++++++ 5 files changed, 300 insertions(+), 4 deletions(-) create mode 100644 A00_Common/design/design_pipeline_probe.ts create mode 100644 A00_Common/design/logic/design_pipeline_probe_station.ts create mode 100644 resources/tester/helper_368_4_probe_station.cjs create mode 100644 resources/tester/test_368_4_probe_station.py diff --git a/A00_Common/design/design_pipeline_probe.ts b/A00_Common/design/design_pipeline_probe.ts new file mode 100644 index 000000000..c653dd48a --- /dev/null +++ b/A00_Common/design/design_pipeline_probe.ts @@ -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 { + 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>; + 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[0]; + return pipelineCrossAt(one, chainageM, intervalM, undefined); +} diff --git a/A00_Common/design/logic/design_pipeline_probe_station.ts b/A00_Common/design/logic/design_pipeline_probe_station.ts new file mode 100644 index 000000000..79e204956 --- /dev/null +++ b/A00_Common/design/logic/design_pipeline_probe_station.ts @@ -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, + curves: ReadonlyArray, + stations: ReadonlyArray, + 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, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts index e399fb585..045bf2238 100644 --- a/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts +++ b/B05_Profile/B05_Profile_UI_Profile_CrossModal.ts @@ -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); }, }; diff --git a/resources/tester/helper_368_4_probe_station.cjs b/resources/tester/helper_368_4_probe_station.cjs new file mode 100644 index 000000000..66a051959 --- /dev/null +++ b/resources/tester/helper_368_4_probe_station.cjs @@ -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)); diff --git a/resources/tester/test_368_4_probe_station.py b/resources/tester/test_368_4_probe_station.py new file mode 100644 index 000000000..d7de139a9 --- /dev/null +++ b/resources/tester/test_368_4_probe_station.py @@ -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)