diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 1576a6a01..5df201796 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -127,7 +127,14 @@ export function draftSectionDetail( ...(station.ground?.left ?? []).map((s) => sample(s.offsetM, s.z)), ]; const design = cross?.byKey[station.key]?.detail as CrossSection["design"] | undefined; - return { ...head, ...own, samples, ...(design ? { design } : {}), preview: true }; + // 설계는 사본 — 구조물 면적 얹기(`applyStructureAreaRows`)가 cross 단계 기억을 안 고침(349-0j) + return { + ...head, + ...own, + samples, + ...(design ? { design: { ...design } } : {}), + preview: true, + }; }); const { chainageM, z } = ground.longitudinal; return { diff --git a/B06_Section/logic/B06_Section_Cross_Stage.ts b/B06_Section/logic/B06_Section_Cross_Stage.ts index efb4b4839..ddb86a2f8 100644 --- a/B06_Section/logic/B06_Section_Cross_Stage.ts +++ b/B06_Section/logic/B06_Section_Cross_Stage.ts @@ -4,6 +4,8 @@ * 측점 열쇠마다 횡단 설계(`CrossOutput.byKey`). * * · 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝 · Cross_Refresh 와 같은 엔진). + * · 미교차 넓힘 = 비탈이 표본 끝에서 원지반과 안 만나면 열린 쪽만 +5 m 씩 지형 격자에서 다시 떠 셈 + * (`extendUnclosed` ↔ 서버 [저장] `B06_Section_Engine_SampleExtend.extend_unclosed` · 349-0j) — 격자가 없으면 안 넓힘. * · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로. * · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있고 계획고가 그대로면 셈 없이 재사용 — 노선 초안의 * 첫 계획선은 바뀐 구간 앞 꼭짓점까지 움직이므로 범위만 보면 낡은 설계가 남음(349-0f). @@ -21,6 +23,8 @@ import type { } from "../../A00_Common/design/logic/design_pipeline_types"; import { planElevationAt } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { groundSamples } from "./B06_Section_Station_Swap"; +import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground"; +import type { DtmGrid } from "@util/common_util_surface_sampler"; export { groundSamples }; @@ -67,10 +71,15 @@ const sameSpot = (kept: CrossStationResult, station: PipelineStation, elevation: Number((kept.detail as { design_elevation_m?: number } | null)?.design_elevation_m) - elevation, ) <= 1e-4; +/** 단계가 받는 것 — 설계 지형 격자(ground 단계와 같은 `groundStageDeps(projectId).grid`). */ +export interface CrossStageDeps { + grid?: () => { grid: DtmGrid } | null; +} + /** * `cross` 단계 만들기 — 측점 지문 기억은 이 단계 함수가 사는 동안. `counts()` = 마지막 계산의 다시 셈 · 재사용 수. */ -export function createCrossStage() { +export function createCrossStage(deps: CrossStageDeps = {}) { const memo = new Map(); let last = { computed: 0, reused: 0 }; @@ -102,6 +111,17 @@ export function createCrossStage() { let design; try { design = computeCrossDesign(samples, elevation, options); + const grid = design.slope_unclosed ? deps.grid?.()?.grid : null; + const wide = + grid && + extendUnclosed( + samples, + [station.x, station.y], + station.left, + (next) => computeCrossDesign(next as typeof samples, elevation, options), + grid, + ); + if (wide) design = computeCrossDesign(wide.samples as typeof samples, elevation, options); } catch { continue; // 표본 부족 측점은 건너뜀(Cross_Refresh · 서버 엔진과 같은 태도) } diff --git a/resources/tester/helper_349_0j_draft_vs_saved.cjs b/resources/tester/helper_349_0j_draft_vs_saved.cjs new file mode 100644 index 000000000..2bd52a0cf --- /dev/null +++ b/resources/tester/helper_349_0j_draft_vs_saved.cjs @@ -0,0 +1,265 @@ +/* PLAN 349-0j 헬퍼 — 노선 초안 미리보기(계산 묶음 cross) 대 [저장] 정본: 미교차 넓힘. + * 진짜 TS(`design_pipeline_page` · 단계 파일 전부)를 트랜스파일해 Node 로 돌림 · 서버 창구만 가짜(틀 = + * `helper_346_2_pipeline_cross_page.cjs`). cross 단계에 설계 지형 격자를 넘김(묶음이 넘길 몫 — 넘기기 전/뒤 둘 다 셈). + * 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_349_0j_draft_vs_saved.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 job = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); + +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; + }, + }; +} +globalThis.window = { + sessionStorage: memoryStorage(), + localStorage: memoryStorage(), + setTimeout, + clearTimeout, +}; + +// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 +const haul = { calls: 0, aborted: 0, answered: 0 }; +globalThis.fetch = (url, init) => + new Promise((resolve, reject) => { + haul.calls += 1; + const timer = setTimeout(() => { + haul.answered += 1; + resolve({ ok: true, json: async () => ({ haul_plan: { blocks: [], url } }) }); + }, job.haulDelayMs); + init.signal.addEventListener("abort", () => { + clearTimeout(timer); + haul.aborted += 1; + reject(new Error("aborted")); + }); + }); + +const STUBS = {}; +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; + const key = Object.keys(STUBS).find((name) => request.endsWith(name)); + if (key) return STUBS[key]; + if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" }; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + 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 load = (...parts) => loadTs(path.join(ROOT, ...parts)); + +const sampler = load("common_util", "common_util_surface_sampler.ts"); +const bytes = fs.readFileSync(process.argv[3]); +const grid = sampler.parseDtmGrid( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.length), +); +const local = { grid, design: "las" }; + +STUBS["B05_Profile_Api_Replan"] = { + analysisTerrain: () => "las", + fetchRoutePlan: async () => ({ + planned: job.line, + curves: [], + min_radius_m: job.minRadius, + station_snap: false, + }), +}; +STUBS["B05_Profile_Api_Structures"] = { + fetchStructures: async () => ({ structures: [] }), + fetchStructureTypes: async () => job.types, +}; +STUBS["B05_Profile_Api_TerrainGrid"] = { + loadDesignTerrainGrid: async () => local, + groundStageDeps: () => ({ grid: () => local, remote: async () => [] }), +}; +STUBS["B04_PreProcess_Api_Fetch"] = { + fetchDetailPipePoints: async () => ({ + pipe_points: job.pipes.map((pipe) => ({ ...pipe, lonlat: [0, 0] })), + }), +}; +STUBS["B06_Section_UI_Standard_Panel"] = { effectiveStandardCross: () => job.standard }; +STUBS["B06_Section_Cross_Refresh"] = { + holdScreenAlignment: () => () => undefined, + refreshCrossDesigns: async () => [], +}; +STUBS["B06_Section_UI_Section_Common"] = { + staleChainages: (key) => (key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : []), +}; + +// 묶음이 cross 단계에 설계 지형 격자를 넘김(ground 단계와 같은 `groundStageDeps(projectId).grid`) +let withGrid = false; +const realCross = load("B06_Section", "logic", "B06_Section_Cross_Stage.ts"); +STUBS["B06_Section_Cross_Stage"] = { + ...realCross, + createCrossStage: () => realCross.createCrossStage(withGrid ? { grid: () => local } : {}), +}; +const pipeMod = load("A00_Common", "design", "logic", "design_pipeline.ts"); +const pageMod = load("A00_Common", "design", "design_pipeline_page.ts"); +const stationsMod = load("A00_Common", "design", "logic", "design_pipeline_stations.ts"); +const routeGround = load("B05_Profile", "logic", "B05_Profile_Pipeline_RouteGround.ts"); +const grade = load("B05_Profile", "logic", "B05_Profile_Pipeline_Grade.ts"); +const gradeGround = load("B05_Profile", "logic", "B05_Profile_Grade_Ground.ts"); +const detailMod = load("B05_Profile", "logic", "B05_Profile_Pipeline_Detail.ts"); +const alignment = load("B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"); +const editMod = load("B05_Profile", "B05_Profile_UI_Profile_Edit.ts"); +const page = load("A00_Common", "b_page_state.ts"); + +const tick = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +const runs = () => globalThis.__aisloPipeline?.runs ?? []; +async function settle(count) { + for (let i = 0; i < 400; i += 1) { + const last = runs()[runs().length - 1]; + if (runs().length >= count && last && last.outcome !== "running") return last; + await tick(5); + } + throw new Error("묶음이 끝나지 않음"); +} +const n = job.nodes.length; +const draftOf = (nodes) => ({ + nodes, + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(null), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + stationSnap: false, +}); +const shift = (index, dx, dy) => + job.nodes.map((node, i) => (i === index ? [node[0] + dx, node[1] + dy] : node)); + +/** 저장 기준 — 저장 노선 지반 첫 계획선(관 측점 포함 · 서버 정책 그대로). */ +async function savedBaseOf() { + const seed = pipeMod.createDesignPipeline({ + stages: { + route: routeGround.createRouteStage({ + minRadiusM: () => job.minRadius, + savedLine: () => ({ line: job.line, curves: [] }), + }), + stations: stationsMod.createStationsStage({ + intervalM: job.interval, + types: () => job.types, + }), + ground: routeGround.createGroundStage({ grid: () => local, remote: async () => [] }), + }, + }); + await seed.start({ route: null, alignment: null, cross: null, pipes: job.pipes, structures: [] }); + const ground = seed.output("ground"); + seed.dispose(); + const samples = ground.longitudinal.chainageM.map((c, i) => ({ + chainage_m: c, + elevation_m: ground.longitudinal.z[i], + })); + const profile = gradeGround.groundProfile(samples); + return { + policy: job.policy, + onlyExplicitCurves: false, + basePvi: gradeGround.groundFollowingBasePvi({ + samples, + stations: ground.stations.map((s) => s.chainageM), + policy: job.policy, + }), + chainage: profile.chainage, + ground: profile.ground, + stations: ground.stations.map((s) => ({ + station_id: grade.stationIdOf(s.chainageM), + chainage_m: s.chainageM, + })), + }; +} + +async function draftRun(P, grid) { + withGrid = grid; + window.localStorage.setItem("frd_current_project_id", P); + const savedBase = await savedBaseOf(); + const seedRuns = runs().length; + const link = { + pipelineSource: () => ({ + base: savedBase, + edits: alignment.emptyEdits(), + detail: job.detail, + routeId: 7, + }), + adoptPipeline: () => undefined, + redraw: () => undefined, + }; + const bridge = pageMod.routeDraftBridge(P, link, { stationIntervalM: job.interval }); + pageMod.pipelineEarthwork(P, { conversion: job.conversion }); + const pipe = pageMod.pagePipeline(P); + await settle(seedRuns + 1); + const [a] = job.drags; + const moved = job.nodes.map((node, i) => + i === a.index ? [node[0] + a.dx, node[1] + a.dy] : node, + ); + bridge.edited(draftOf(moved)); + const run = await settle(runs().length + 1); + await tick(job.haulDelayMs + 20); + const profile = pipe.output("profile"); + const cross = pipe.output("cross"); + const shown = pageMod.pipelineDetail(P); + const stations = pipe.output("ground").stations.map((s) => { + const design = cross.byKey[s.key]?.detail ?? null; + const line = design?.design_line ?? []; + return { + key: s.key, + chainage: s.chainageM, + elevation: alignment.planElevationAt(profile, s.chainageM), + cut: design?.cut_area_m2 ?? null, + fill: design?.fill_area_m2 ?? null, + unclosed: design?.slope_unclosed ?? null, + lineEnds: line.length ? [line[0].offset_m, line[line.length - 1].offset_m] : null, + }; + }); + const copied = shown.cross_sections.every( + (section, i) => + !section.design || + section.design !== cross.byKey[pipe.output("ground").stations[i].key]?.detail, + ); + const out = { + outcome: run.outcome, + totalMs: run.totalMs, + line: pipe.output("route").line, + curves: pipe.output("route").curves, + final: pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null, + stations, + copied, + }; + pageMod.disposePagePipeline(P); + page.clearDrafts(P); + return out; +} + +async function run() { + const plain = await draftRun("proj-3490j-a", false); + const wide = await draftRun("proj-3490j-b", true); + return { plain, wide }; +} + +run() + .then((out) => { + process.stdout.write(JSON.stringify(out)); + process.exit(0); + }) + .catch((error) => { + process.stderr.write(String(error && error.stack ? error.stack : error)); + process.exit(1); + }); diff --git a/resources/tester/test_349_0j_draft_vs_saved.py b/resources/tester/test_349_0j_draft_vs_saved.py new file mode 100644 index 000000000..1c89f5d51 --- /dev/null +++ b/resources/tester/test_349_0j_draft_vs_saved.py @@ -0,0 +1,175 @@ +"""PLAN 349-0j — 노선 초안 미리보기 누가토량 대 [저장] 정본. + +갈린 자리(검증_sub2 · 2번째 꺾임점 서쪽 20 m · 서버 체인을 실제로 돌리고 체인 스스로 되살림): + 미리보기 = 서버 이월 직후(보정 전)와 측점 · 계획고 · 면적이 같음(-11,378.5 대 -11,391.3 ㎥) + · [확정] 보정 중 미교차 넓힘(표본 20 m → 지표면 끝) -9,531 ㎥ · 관 구조물 면적 -334 ㎥(넓힌 뒤 기준 1,971 ㎥). +· cross 단계에 설계 지형 격자를 넘기면 미교차 측점을 서버와 같은 규칙(`extendUnclosed` ↔ + `extend_unclosed`)으로 넓힘 — 넓힌 끝 · 면적 · 누가토량이 서버 엔진과 같음 +· 격자를 안 넘기면(지금 묶음) 넓힘 없음 — 서버와 갈림 +· 초안 상세의 설계 = cross 단계 결과의 사본(구조물 면적 얹기가 단계 기억을 안 고침) +짝: `helper_349_0j_draft_vs_saved.cjs` · 지형 · 노선 · 관 틀 = `test_346_2_pipeline_cross_page.py`. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +for path in (ROOT, HERE): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options # noqa: E402 +from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402 + design_ground_following_profile, +) +from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402 +from B06_Section.B06_Section_Engine_SampleExtend import extend_unclosed # noqa: E402 +from common_util.common_util_surface_sampler import DtmGridSampler, dtm_grid_bytes # noqa: E402 +from config.config_system_design import STANDARD_CROSS_SECTION # noqa: E402 +from test_346_2_pipeline_cross_page import ( # noqa: E402 + INTERVAL_M, + MIN_RADIUS_M, + ONES, + _fake, + _line, + _pipes, + _real, + _saved_detail, + _sections, + _types, +) + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +def _server(ts: dict, sampler) -> list[dict]: + """[저장] 정본 길 — 같은 노선 서버 측점 · 지반 → 엔진 설계 → 미교차 넓힘(확정 보정).""" + pipes = [ + {"id": s["key"].split(":", 1)[1], "chainage_m": s["chainage"], "source": "user"} + for s in ts["stations"] + if s["key"].startswith("pipe:") + ] + data = _sections(ts["line"], sampler, pipes, ts["curves"]) + out = [] + for a, b in zip(ts["stations"], data["cross_sections"]): + assert abs(a["chainage"] - b["chainage_m"]) < 1e-3 + + def compute(samples, a=a, b=b): + return compute_cross_design( + samples, + a["elevation"], + ground_type="ripping_rock", + section_mode=f"{b.get('uphill_side') or 'left'}_cut", + plan_radius_m=b.get("plan_radius_m"), + curve_outer_side=b.get("curve_outer_side"), + curve_widening_m=b.get("curve_widening_m"), + ) + + design = compute(b["samples"]) + wide = extend_unclosed(b["samples"], b["frame"], compute, sampler) + if wide is not None and (wide[1]["left_m"] or wide[1]["right_m"]): + design = compute(wide[0]) + line = design["design_line"] + out.append( + { + "chainage": b["chainage_m"], + "cut": design["cut_area_m2"], + "fill": design["fill_area_m2"], + "lineEnds": [line[0]["offset_m"], line[-1]["offset_m"]], + } + ) + return out + + +def _final(rows) -> float: + nets = [(r["chainage"], r["cut"] - r["fill"]) for r in rows] + return sum((n0 + n1) / 2 * (c1 - c0) for (c0, n0), (c1, n1) in zip(nets, nets[1:])) + + +@pytest.fixture(scope="module", params=[_fake, _real], ids=["가짜", "검증_sub7"]) +def r(request) -> dict: + with tempfile.TemporaryDirectory() as folder: + npz, nodes = request.param(Path(folder)) + line = _line(nodes) + sampler = DtmGridSampler.from_npz(npz) + pipes = _pipes(line) + saved = _sections(line, sampler, pipes) + options = resolve_grade_options("trunk", terrain_type="normal", paved=False, stored=None) + policy = design_ground_following_profile(saved["longitudinal"], options)[0]["policy"] + job = { + "nodes": nodes, + "line": line, + "minRadius": MIN_RADIUS_M, + "policy": policy, + "types": _types(), + "interval": INTERVAL_M, + "pipes": pipes, + "standard": STANDARD_CROSS_SECTION, + "conversion": ONES, + "detail": _saved_detail(saved), + "haulDelayMs": 20, + "drags": [{"index": 1, "dx": -20.0, "dy": 0.0}], + } + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + grid = Path(folder) / "grid.bin" + grid.write_bytes(dtm_grid_bytes(npz)) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_349_0j_draft_vs_saved.cjs"), str(path), str(grid)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=180, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + out["server"] = _server(out["wide"], sampler) + out["kind"] = request.param.__name__ + return out + + +def test_격자를_넘기면_서버와_같은_넓힘_면적(r): + wide, server = r["wide"], r["server"] + assert wide["outcome"] == "done" + assert len(wide["stations"]) == len(server) + for a, b in zip(wide["stations"], server): + assert a["lineEnds"] == pytest.approx(b["lineEnds"]), a["key"] + assert abs(a["cut"] - b["cut"]) < 0.05 + 0.01 * b["cut"], a["key"] + assert abs(a["fill"] - b["fill"]) < 0.05 + 0.01 * b["fill"], a["key"] + + +def test_미리보기_누가토량_서버_정본과_같음(r): + want = _final(r["server"]) + got = r["wide"]["final"] + assert got is not None + assert abs(got - want) <= max(1.0, abs(want) * 0.005), (got, want) + + +def test_격자_없으면_넓힘_없음(r): + plain, wide = r["plain"], r["wide"] + widened = [ + s["key"] for s in wide["stations"] if s["lineEnds"] and max(map(abs, s["lineEnds"])) > 20.01 + ] + assert all( + s["lineEnds"] is None or max(map(abs, s["lineEnds"])) <= 20.01 for s in plain["stations"] + ) + if r["kind"] == "_real": # 실제 지형 — 미교차가 있어 넓힘이 누가토량을 바꿈 + assert widened + assert abs(plain["final"] - wide["final"]) > 1.0 + else: + assert plain["final"] == pytest.approx(wide["final"]) or widened + + +def test_초안_상세_설계는_사본(r): + assert r["wide"]["copied"] is True