From 22eaed6509fc85ef11c5c52c47c97bca8c778c1c Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 19:35:02 +0900 Subject: [PATCH] =?UTF-8?q?fix(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EC=B0=BD=20=ED=8C=8C=EB=9E=80?= =?UTF-8?q?=20=EB=B0=98=ED=8F=AD=20=EC=84=A0=20=EA=B8=B8=EC=9D=B4=EB=A5=BC?= =?UTF-8?q?=20=E3=80=8C=ED=9A=A1=EB=8B=A8=20=EB=B0=98=ED=8F=AD=E3=80=8D=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=EA=B0=92=20=EC=96=91=EC=AA=BD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20(PLAN=2022-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단 미리보기 요청에 적용 반폭을 실어 보내고 서버가 덮은 반폭을 되돌려 줌 - 파란 선 좌·우 길이 = 그 반폭 (원지반 선 범위와 같은 값) · 적용한 적 없으면 서버 기본값 - 시험 test_b05_routeedit_cross_width.py 추가 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- B05_Profile/B05_Profile_Api_Replan.ts | 4 + B05_Profile/B05_Profile_Router_Terrain.py | 12 +- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 35 ++++- .../B05_Profile_UI_RouteEdit_Render.ts | 3 +- .../helper_b05_routeedit_cross_width.cjs | 122 ++++++++++++++++++ .../tester/test_b05_routeedit_cross_width.py | 51 ++++++++ 6 files changed, 219 insertions(+), 8 deletions(-) create mode 100644 resources/tester/helper_b05_routeedit_cross_width.cjs create mode 100644 resources/tester/test_b05_routeedit_cross_width.py diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index b1690721..a2027ab0 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -164,6 +164,8 @@ export interface CrossPreviewResponse { uphill_side: string | null; plan_radius_m: number | null; curve_widening_m: number | null; + /** 샘플이 덮은 횡단 반폭(m) — 요청값, 비었으면 서버 기본값. */ + cross_half_width_m: number; /** 원지반 횡단 샘플. */ samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>; /** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */ @@ -180,6 +182,8 @@ export interface CrossPreviewRequest { chainage_m: number; min_radius_m: number; station_interval_m: number; + /** 횡단 반폭(m) — 없으면 서버 기본값. */ + cross_half_width_m?: number; } /** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */ diff --git a/B05_Profile/B05_Profile_Router_Terrain.py b/B05_Profile/B05_Profile_Router_Terrain.py index db032f93..2d0f8547 100644 --- a/B05_Profile/B05_Profile_Router_Terrain.py +++ b/B05_Profile/B05_Profile_Router_Terrain.py @@ -118,6 +118,8 @@ class CrossPreviewRequest(BaseModel): #: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다. min_radius_m: float = Field(12.0, gt=0) station_interval_m: float | None = None + #: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값. + cross_half_width_m: float | None = Field(None, gt=0) def _cross_preview( @@ -155,10 +157,12 @@ def _cross_preview( radii=[vertex.radius_m for vertex in request.vertices], ) interval = request.station_interval_m - options = ( - SectionGenerationOptions(station_interval_m=float(interval)) + defaults = SectionGenerationOptions() + options = SectionGenerationOptions( + station_interval_m=float(interval) if interval and interval > 0 - else SectionGenerationOptions() + else defaults.station_interval_m, + cross_half_width_m=request.cross_half_width_m or defaults.cross_half_width_m, ) result = generate_sections(built.vertices, sampler, options) sections = result["cross_sections"] @@ -184,6 +188,8 @@ def _cross_preview( "uphill_side": section.get("uphill_side"), "plan_radius_m": section.get("plan_radius_m"), "curve_widening_m": section.get("curve_widening_m"), + # 샘플이 실제로 덮은 반폭 — 평면도 반폭 선이 이 값을 그대로 쓴다. + "cross_half_width_m": options.cross_half_width_m, "samples": section["samples"], "design": design, "total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 65065cb9..def15d29 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -21,9 +21,9 @@ import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_ import { buildCrossSvg, CROSS_PLOT_PAD, - designHalfWidths, summarizeCross, } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; +import { readByKey, stateKey } from "../A00_Common/b_page_state"; import { stationLabel as formatStation } from "../common_util/common_util_station"; export interface CrossPreviewParams { @@ -40,7 +40,7 @@ export interface CrossPreviewParams { onShown?: () => void; } -/** 위 판에 떠 있는 측점과 그 좌·우 설계 반폭(m) — 평면도가 반폭 선을 그리는 재료. */ +/** 위 판에 떠 있는 측점과 그 좌·우 횡단 반폭(m) — 평면도가 반폭 선을 그리는 재료. */ export interface CrossWatch { chainageM: number; leftM: number; @@ -64,6 +64,26 @@ interface CrossPane { wait: (text: string) => void; } +/** + * 우측 패널 「횡단 반폭(m)」에 **적용해 둔 값**(m). 적용한 적이 없으면 undefined — 서버가 기본값을 쓴다. + * + * B06 이 [적용]에서 노선 단위 세션 키에 적는다. 이 창은 노선 번호를 몰라 프로젝트 접두어로 + * 훑고, 노선이 여럿 남아 있으면 번호가 가장 큰(최신) 것을 쓴다. + */ +function appliedHalfWidth(projectId: string): number | undefined { + const prefix = stateKey("cross-display", projectId, ""); + if (!prefix) return undefined; + try { + const latest = Object.keys(window.sessionStorage) + .filter((key) => key.startsWith(prefix)) + .sort((a, b) => Number(b.slice(prefix.length)) - Number(a.slice(prefix.length)))[0]; + const value = Number(latest ? readByKey(latest) : NaN); + return Number.isFinite(value) && value > 0 ? value : undefined; + } catch { + return undefined; + } +} + function createPane(title: string, empty: string): CrossPane { const root = document.createElement("section"); root.className = "b05-routeedit__cross"; @@ -139,6 +159,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind const preview = await fetchCrossPreview(params.projectId, { ...request, chainage_m: chainageM, + cross_half_width_m: appliedHalfWidth(params.projectId), }); if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다. shown = preview; @@ -164,8 +185,14 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind await load(watching, true); }, // 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다. - // 반폭은 마지막으로 셈한 횡단 것이고, 노드를 놓으면 `refresh` 가 새 값으로 바꾼다. + // 반폭은 마지막으로 셈한 횡단이 덮은 폭 — 좌·우 같고, 원지반 선 범위와 한 값이다. watched: () => - watching === null || !shown ? null : { chainageM: watching, ...designHalfWidths(shown) }, + watching === null || !shown + ? null + : { + chainageM: watching, + leftM: shown.cross_half_width_m, + rightM: shown.cross_half_width_m, + }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 7e603d50..c01eb9ef 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -344,7 +344,8 @@ function drawMeasureMarks( } /** - * 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 그 측점 설계 반폭. + * 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 우측 패널 「횡단 반폭」 + * (평면 거리 · 횡단 미리보기의 원지반 선 범위와 같다). * * 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버 * `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다. diff --git a/resources/tester/helper_b05_routeedit_cross_width.cjs b/resources/tester/helper_b05_routeedit_cross_width.cjs new file mode 100644 index 00000000..8b914607 --- /dev/null +++ b/resources/tester/helper_b05_routeedit_cross_width.cjs @@ -0,0 +1,122 @@ +/* 계획노선 편집 창 **측점 반폭 선 길이**(계획서 22-1) 단위검증 헬퍼 — + * `B05_Profile_UI_RouteEdit_Cross.ts` 를 트랜스파일해 Node 로 돌린다. + * 서버 묻기 · 그림 조각은 가짜로 채운다 — 「무슨 반폭을 묻고 · 무슨 반폭으로 선을 잡나」만 본다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const ROOT = path.join(__dirname, "..", ".."); + +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; + }, + }; +} +const session = memoryStorage(); +Object.defineProperty(session, "length", { get: () => session._keys().length }); +session._keys = () => { + const keys = []; + for (let i = 0; i < 1000; i += 1) { + const key = session.key(i); + if (key === null) break; + keys.push(key); + } + return keys; +}; +// `Object.keys(sessionStorage)` 가 저장된 키를 돌려주도록 실제 브라우저처럼 속성으로도 둔다. +const sessionProxy = new Proxy(session, { + ownKeys: () => [...new Set(session._keys())], + getOwnPropertyDescriptor: (target, key) => + session._keys().includes(key) + ? { enumerable: true, configurable: true, value: target.getItem(key) } + : Reflect.getOwnPropertyDescriptor(target, key), +}); +globalThis.window = { localStorage: memoryStorage(), sessionStorage: sessionProxy }; + +const fakeEl = () => ({ + className: "", + textContent: "", + innerHTML: "", + querySelector: () => fakeEl(), + replaceChildren() {}, + append() {}, + getBoundingClientRect: () => ({ width: 300, height: 200 }), +}); +globalThis.document = { createElement: fakeEl }; + +const asked = []; +const STUBS = { + "../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan: () => {} }, + "../common_util/common_util_station": { stationLabel: (m) => String(m) }, + "./B05_Profile_UI_RouteEdit_Cross_Draw": { + buildCrossSvg: () => ({ svg: {}, layer: {}, width: 1, height: 1, content: {} }), + CROSS_PLOT_PAD: 0, + summarizeCross: () => "", + }, + // 서버 흉내 — 요청 반폭을 그대로 되돌려 주고, 없으면 기본 20. + "./B05_Profile_Api_Replan": { + fetchCrossPreview: async (_project, request) => { + asked.push(request.cross_half_width_m ?? null); + return { + chainage_m: request.chainage_m, + cross_half_width_m: request.cross_half_width_m ?? 20, + samples: [], + design: null, + }; + }, + }, +}; + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + 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 (STUBS[request]) return STUBS[request]; + 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); + cache.set(full, box.exports); + return box.exports; +} + +const { createCrossPreview } = loadTs( + path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts"), +); + +async function run() { + const cross = createCrossPreview({ + projectId: "p-1", + side: { append() {} }, + request: () => ({ vertices: [], min_radius_m: 12, station_interval_m: 20 }), + }); + const out = {}; + out.before = cross.watched(); + await cross.open(100); + out.empty = { asked: asked.at(-1), watched: cross.watched() }; + session.setItem("aislo:draft:cross-display:p-1:3", "15"); + await cross.refresh(); + out.applied15 = { asked: asked.at(-1), watched: cross.watched() }; + session.setItem("aislo:draft:cross-display:p-1:12", "8.5"); + session.setItem("aislo:draft:cross-display:p-2:99", "40"); + await cross.refresh(); + out.latestRoute = { asked: asked.at(-1), watched: cross.watched() }; + session.setItem("aislo:draft:cross-display:p-1:12", "abc"); + await cross.refresh(); + out.badValue = { asked: asked.at(-1), watched: cross.watched() }; + return out; +} +run().then((out) => console.log(JSON.stringify(out))); diff --git a/resources/tester/test_b05_routeedit_cross_width.py b/resources/tester/test_b05_routeedit_cross_width.py new file mode 100644 index 00000000..2242aaf2 --- /dev/null +++ b/resources/tester/test_b05_routeedit_cross_width.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +"""계획노선 편집 창 — **측점 반폭 선 길이**(계획서 22-1) 회귀검증. + +파란 선 = 우측 패널 「횡단 반폭(m)」 적용값 양쪽. 적용한 적이 없으면 서버 기본값. +선 길이는 서버가 덮은 반폭(= 횡단 미리보기 원지반 선 범위)을 그대로 쓴다. +Node 헬퍼(`helper_b05_routeedit_cross_width.cjs`)가 TS 를 트랜스파일해 돌린다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_routeedit_cross_width.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_측점을_고르기_전에는_선이_없다(results): + assert results["before"] is None + + +def test_적용한_적_없으면_서버_기본값_양쪽(results): + assert results["empty"]["asked"] is None + assert results["empty"]["watched"] == {"chainageM": 100, "leftM": 20, "rightM": 20} + + +def test_적용값이_있으면_그_값_양쪽(results): + assert results["applied15"]["asked"] == 15 + assert results["applied15"]["watched"] == {"chainageM": 100, "leftM": 15, "rightM": 15} + + +def test_노선이_여럿이면_최신_노선_값_다른_프로젝트는_무시(results): + assert results["latestRoute"]["asked"] == 8.5 + assert results["latestRoute"]["watched"]["leftM"] == 8.5 + + +def test_망가진_값은_기본값으로(results): + assert results["badValue"]["asked"] is None + assert results["badValue"]["watched"]["rightM"] == 20