From 3e203418102db19b2a98d8a4aba025607ab8b49d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 08:47:40 +0900 Subject: [PATCH] =?UTF-8?q?fix(b06):=20=ED=9A=A1=EB=8B=A8=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=98=ED=8F=AD=20[=EC=A0=81=EC=9A=A9]=20=3D=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EA=B0=92=EB=A7=8C=20=E2=80=94=20=EA=B5=90?= =?UTF-8?q?=EC=B0=A8=EC=A0=90=20=EB=A7=9E=EC=B6=A4=20=ED=8F=AD=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=88=84=EB=A5=BC=20=EB=95=8C=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=9E=AC=EC=83=9D=EC=84=B1=ED=95=98=EB=8D=98=20=EC=98=9B=20?= =?UTF-8?q?=EC=A4=84=20=EC=A7=80=EC=9B=80(173-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P --- A00_Common/design/design_view_cross.ts | 6 +-- B06_Section/B06_Section_UI_Cross_Fit.ts | 45 ++--------------- resources/tester/helper_173_6_half_width.cjs | 50 +++++++++++++++++++ .../tester/test_173_6_half_width_apply.py | 48 ++++++++++++++++++ 4 files changed, 102 insertions(+), 47 deletions(-) create mode 100644 resources/tester/helper_173_6_half_width.cjs create mode 100644 resources/tester/test_173_6_half_width_apply.py diff --git a/A00_Common/design/design_view_cross.ts b/A00_Common/design/design_view_cross.ts index 775dcc8fd..5dbd9f0c3 100644 --- a/A00_Common/design/design_view_cross.ts +++ b/A00_Common/design/design_view_cross.ts @@ -35,7 +35,6 @@ import { crossSaveParts, type SectionPersistContext, } from "../../B06_Section/B06_Section_UI_Page_Persist"; -import { maxToeFitHalfWidth } from "../../B06_Section/B06_Section_UI_Cross_Fit"; import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session"; import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection"; import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft"; @@ -256,11 +255,8 @@ export async function mountCrossView( */ async function applyCrossHalfWidth(): Promise { if (!sectionDetail) return; - // 교차점이 샘플 밖인 측점이 있으면 그 폭까지 넓힌다(카드가 자동 줌아웃할 지반 확보). const requested = crossHalfWidth(); - const needed = maxToeFitHalfWidth(sectionDetail.cross_sections); - const target = Math.max(requested ?? 0, needed); - if (target > 0 && !(await ensureSampledWidth(target))) return; + if (requested && !(await ensureSampledWidth(requested))) return; if (!sectionDetail) return; stationControls.applyGlobalWidth( requested, diff --git a/B06_Section/B06_Section_UI_Cross_Fit.ts b/B06_Section/B06_Section_UI_Cross_Fit.ts index 0a0f6779b..4e9005562 100644 --- a/B06_Section/B06_Section_UI_Cross_Fit.ts +++ b/B06_Section/B06_Section_UI_Cross_Fit.ts @@ -9,8 +9,7 @@ * 교차점이 여전히 화면 밖에 남는다(2026-08-23 사용자 보고). * * 설계선이 지반과 만나기 전에 끝나면(계산 반폭 밖) **추세로 외삽**한다 — 끝단의 사면 - * 기울기와 지반 기울기 차이로 만나는 자리를 추정하고 1m 올림한다. 그 값이 계산 반폭을 - * 넘으면 페이지가 백엔드 재생성으로 샘플을 넓힌다. + * 기울기와 지반 기울기 차이로 만나는 자리를 추정한다. * ========================================================================== */ import type { CrossDesign, CrossSection } from "./B06_Section_Api_Fetch"; @@ -25,43 +24,10 @@ const TREND_SPAN_M = 2; /** 외삽 결과의 상한(m) — 발산하는 지형에서 폭이 무한정 커지는 것을 막는다. */ const MAX_FIT_HALF_WIDTH_M = 60; -/** - * 절·성토 교차점이 모두 들어오는 표시 반폭(m). 설계선·지반선이 없으면 null. - * - * 반환값은 1m 단위 올림 — 교차점이 축선에 딱 붙지 않게 한다. - */ -export function toeFitHalfWidth(section: CrossSection): number | null { - const design = section.design; - if (!design) return null; - const groundAt = groundInterpolator(section.samples); - const designAt = designInterpolator(design.design_line); - const edges = design.road_edges; - if (!groundAt || !designAt || !edges) return null; - - // 설계선이 실제로 정의된 범위 — 그 밖은 외삽 영역이다. - const lineOffsets = design.design_line.map((point) => point.offset_m); - if (lineOffsets.length < 2) return null; - const lineMax = Math.max(...lineOffsets); - const lineMin = Math.min(...lineOffsets); - // 노면·측구 구간은 건너뛴다 — 계획고가 지반과 우연히 겹쳐도 사면 끝이 아니다. - const { protectMax, protectMin } = protectedSpan(design, edges); - - let extent = 0; - for (const side of ["left", "right"] as const) { - const outward = side === "left" ? 1 : -1; - const start = side === "left" ? protectMax : protectMin; - const limit = side === "left" ? lineMax : lineMin; - const meet = meetOffset(designAt, groundAt, start, limit, outward); - extent = Math.max(extent, Math.abs(meet)); - } - if (!(extent > 0)) return null; - return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M); -} - /** * 절·성토 사면이 원지반과 **실제로 만나는** 좌·우 offset(m). 그 측에서 못 만나면 null. * - * `toeFitHalfWidth` 와 같은 교차점을 쓰되 **좌우를 따로** 낸다(2026-09-12 사용자 지시 ②) — + * 교차점을 **좌우를 따로** 낸다(2026-09-12 사용자 지시 ②) — * 반폭 하나로 자르면 한쪽만 성토인 측점에서 반대쪽 원지반이 필요보다 넓게 남는다. * **외삽으로 짚은 자리는 안 쓴다**(null) — 지반을 모르는 자리라 거기까지 그으면 없는 지반을 * 그리는 셈이 된다. @@ -129,7 +95,7 @@ export function fillSlopeLengths(section: CrossSection): { const outward = side === "left" ? 1 : -1; const start = side === "left" ? protectMax : protectMin; const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets); - // 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `toeFitHalfWidth`가 + // 미교차 측점은 **재지 못한 것**이라 계산 반폭까지의 하한값만 준다 — `meetOffset` 이 // 쓰는 추세 외삽은 지반이 사면과 거의 나란한 자리에서 수십 m씩 튀어 길이 표기에는 못 쓴다 // (2026-09-03 실측: 740m 측점 좌측 외삽 101.53m, 실제 반폭 20m까지 고저차 7.16m 유지). const meet = meetOffset(designAt, groundAt, start, limit, outward); @@ -328,8 +294,3 @@ function extrapolateMeet( if (!Number.isFinite(extra)) return limit; return limit + outward * Math.min(extra, MAX_FIT_HALF_WIDTH_M); } - -/** 전 측점 중 교차점을 보려면 필요한 최대 반폭(m). 없으면 0. */ -export function maxToeFitHalfWidth(sections: CrossSection[]): number { - return sections.reduce((max, section) => Math.max(max, toeFitHalfWidth(section) ?? 0), 0); -} diff --git a/resources/tester/helper_173_6_half_width.cjs b/resources/tester/helper_173_6_half_width.cjs new file mode 100644 index 000000000..766c4b026 --- /dev/null +++ b/resources/tester/helper_173_6_half_width.cjs @@ -0,0 +1,50 @@ +/* 횡단 보기 반폭 [적용](PLAN 173-6) 단위검증 헬퍼 — `createSampleWidener` 를 그 자리에서 트랜스파일해 돌린다. + * 보유 샘플(20 m) 안 요청은 재생성 없이 true · 넘는 요청만 재생성 한 번. 결과는 표준출력에 JSON 한 벌. + * 짝 = `test_173_6_half_width_apply.py`. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const calls = { regenerate: [], overlay: 0 }; +const STUBS = { + "@ui/ui_template_locale": { + currentLanguageIndex: 0, + ui_locales: new Proxy({}, { get: () => [""] }), + }, + "@ui/ui_template_elements": { + showLoadingOverlay: () => calls.overlay++, + hideLoadingOverlay: () => {}, + showToast: () => {}, + }, + "./B06_Section_Api_Fetch": { + regenerateSections: async (_p, _r, width) => calls.regenerate.push(width), + fetchSectionDetail: async () => ({ cross_sections: [] }), + }, + "./B06_Section_Section_Store": { replaceSectionDetail: () => {} }, +}; + +const file = path.join(ROOT, "B06_Section", "B06_Section_UI_Page_Common.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, (r) => STUBS[r]); + +(async () => { + let sampled = 20; + const widen = box.exports.createSampleWidener({ + target: () => ({ projectId: "p", routeId: 1 }), + sampledHalfWidth: () => sampled, + applyDetail: () => (sampled = calls.regenerate.at(-1)), + }); + const out = {}; + out.within = [await widen(10), await widen(15), await widen(20)]; + out.withinCalls = calls.regenerate.length; + out.over = await widen(25); + out.overCalls = [...calls.regenerate]; + out.again = await widen(25); // 넓힌 뒤 같은 값은 재생성 없음 + out.againCalls = calls.regenerate.length; + process.stdout.write(JSON.stringify(out)); +})(); diff --git a/resources/tester/test_173_6_half_width_apply.py b/resources/tester/test_173_6_half_width_apply.py new file mode 100644 index 000000000..e41790106 --- /dev/null +++ b/resources/tester/test_173_6_half_width_apply.py @@ -0,0 +1,48 @@ +"""173-6 횡단 보기 반폭 [적용] — 요청값만으로 샘플 폭을 잼(교차점 맞춤 폭 안 씀). + +원지반 못 만나는 측점이 있으면 누를 때마다 서버 재생성하던 옛 줄(`maxToeFitHalfWidth`)을 지움 → +보유 샘플(20 m) 넘는 값일 때만 재생성 · 나머지는 카드 다시 그리기만. +소스(`design_view_cross.ts` · `B06_Section_UI_Cross_Fit.ts`) + `createSampleWidener` 를 돌린 값 +(`helper_173_6_half_width.cjs`). +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] +DVC = (ROOT / "A00_Common" / "design" / "design_view_cross.ts").read_text(encoding="utf-8") +FIT = (ROOT / "B06_Section" / "B06_Section_UI_Cross_Fit.ts").read_text(encoding="utf-8") + + +def _apply_body() -> str: + at = DVC.index("async function applyCrossHalfWidth") + return DVC[at : DVC.index("\n }\n", at)] + + +def test_적용은_요청값만_넘김() -> None: + body = _apply_body() + assert "ensureSampledWidth(requested)" in body + assert "maxToeFitHalfWidth" not in DVC and "Math.max" not in body + + +def test_교차점_맞춤_폭_함수_지움() -> None: + assert "maxToeFitHalfWidth" not in FIT and "toeFitHalfWidth" not in FIT + + +@pytest.mark.skipif(not (ROOT / "config" / "node_modules").is_dir(), reason="의존성 미설치") +def test_보유_폭_안은_재생성_없음_넘을_때만_한_번() -> None: + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_173_6_half_width.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + out = json.loads(done.stdout) + assert out["within"] == [True, True, True] and out["withinCalls"] == 0 + assert out["over"] is True and out["overCalls"] == [25] + assert out["again"] is True and out["againCalls"] == 1