From 50620a47b844ace6f661e37b528b68ed37fabcef Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:30:16 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=EB=B0=9C=EC=9E=90=EA=B5=AD):=20346-?= =?UTF-8?q?2=20footprint=20=EB=8B=A8=EA=B3=84=20=E2=80=94=20cross=20=C2=B7?= =?UTF-8?q?=20ground=20=E2=86=92=20=EA=B3=84=EC=95=BD=20PlanFootprint=20?= =?UTF-8?q?=EA=BC=B4=20=C2=B7=20=EB=B2=94=EC=9C=84=20=EB=B0=96=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - B05_Profile/logic/B05_Profile_Footprint_Stage.ts — footprintStage(ctx) · 측점 지반(우 = 음수 오프셋 · 빈칸 무효) + cross.byKey[key].detail(road_edges · design_line) → 측점 줄 · 바뀐 범위 밖은 ctx.prev.records 같은 열쇠 · 같은 누가거리 줄 그대로 - PlanFootprint.ts — footprintLines(계약 roadLeft · roadRight · slopeLeft · slopeRight + 점마다 깎기 여부 · 눈금 · 측점 줄) · 옛 꼴 이름 SlopePieces · 몸통 sampleLines 하나 - 시험 test_346_2_footprint_stage.py · 거울 시험 tsc 에 --noCheck --noResolve Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../logic/B05_Profile_Footprint_Stage.ts | 100 ++++++++++ .../logic/B05_Profile_PlanFootprint.ts | 75 +++++++- .../tester/test_346_2_footprint_stage.py | 181 ++++++++++++++++++ .../test_346_2_plan_footprint_mirror.py | 3 +- 4 files changed, 350 insertions(+), 9 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_Footprint_Stage.ts create mode 100644 resources/tester/test_346_2_footprint_stage.py diff --git a/B05_Profile/logic/B05_Profile_Footprint_Stage.ts b/B05_Profile/logic/B05_Profile_Footprint_Stage.ts new file mode 100644 index 000000000..ede536f16 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_Footprint_Stage.ts @@ -0,0 +1,100 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_Footprint_Stage.ts + * 계산 묶음 footprint 단계(346-2) — cross · ground → 평면 발자국(계약 `PlanFootprint` 꼴). + * + * 측점마다 = 지반 표본(ground) + 그 측점 횡단 결과(cross.byKey[key].detail = `computeCrossDesign` + * 결과 꼴 · road_edges · design_line) → `footprintRecord`(B07 `slope_records` 한 줄 짝). + * 바뀐 범위 밖 측점은 앞 결과(ctx.prev.records)의 같은 열쇠 · 같은 누가거리 줄을 그대로 씀. + * 선은 노선(route 단계 · 원호 섞임)을 따라 측점 · 2 m 마다 다시 이음(가벼움 · 전 구간). + * + * 순수 계산 — 화면 · three · fetch · sessionStorage 없음. + * ========================================================================== */ + +import type { + ChangeRange, + CrossStationResult, + PipelineStation, + StageContext, +} from "../../A00_Common/design/design_pipeline_types"; +import { + footprintLines, + footprintRecord, + type FootprintLines, + type FootprintPoint, + type FootprintRecord, + type FootprintStation, +} from "./B05_Profile_PlanFootprint"; + +/** 같은 측점으로 볼 누가거리 차(m) — 측점 열쇠 규칙 0.01 m 의 절반. */ +const SAME_CHAINAGE_M = 0.005; + +const sample = (offset: number, z: number | null): FootprintPoint => ({ + offset_m: offset, + elevation_m: z, + valid: z !== null, +}); + +/** 측점 + 그 측점 횡단 결과 → 발자국 입력 한 자리(지반 = 우 바깥 → 중심 → 좌 바깥). */ +export function footprintStation( + station: PipelineStation, + cross: CrossStationResult | undefined, +): FootprintStation { + const ground = station.ground; + const samples: FootprintPoint[] = []; + if (ground) { + for (const item of [...ground.right].reverse()) samples.push(sample(-item.offsetM, item.z)); + samples.push(sample(0, ground.centerZ)); + for (const item of ground.left) samples.push(sample(item.offsetM, item.z)); + } + const [lx, ly] = station.left; + return { + chainage_m: station.chainageM, + x: station.x, + y: station.y, + // 좌측 = (−ty, tx) 이므로 진행 방향 = (ly, −lx) + tx: ly, + ty: -lx, + design: (cross?.detail ?? null) as FootprintStation["design"], + samples, + }; +} + +function changed(range: ChangeRange | null, station: PipelineStation): boolean { + if (!range) return true; + if (range.keys.includes(station.key)) return true; + return ( + station.chainageM >= range.fromM - SAME_CHAINAGE_M && + station.chainageM <= range.toM + SAME_CHAINAGE_M + ); +} + +/** footprint 단계 — `createDesignPipeline({ stages: { footprint: footprintStage } })`. */ +export function footprintStage(ctx: StageContext<"footprint">): FootprintLines { + const stations = ctx.out.ground?.stations ?? []; + const byKey = ctx.out.cross?.byKey ?? {}; + const before = new Map(); + for (const record of (ctx.prev as FootprintLines | null)?.records ?? []) { + if (record.key) before.set(record.key, record); + } + const records: FootprintRecord[] = []; + for (const station of stations) { + const old = before.get(station.key); + if ( + old && + !changed(ctx.range, station) && + Math.abs(old.chainage_m - station.chainageM) < SAME_CHAINAGE_M + ) { + records.push(old); + continue; + } + const record = footprintRecord(footprintStation(station, byKey[station.key])); + if (record) records.push({ ...record, key: station.key }); + } + const route = ctx.out.route; + const line: Array<[number, number, number]> = + route && route.line.length >= 2 + ? route.line.map(([x, y], index) => [route.chainage[index], x, y]) + : stations.map((station) => [station.chainageM, station.x, station.y]); + const endM = route?.lengthM ?? line[line.length - 1]?.[0] ?? 0; + return footprintLines(line, records, 0, endM); +} diff --git a/B05_Profile/logic/B05_Profile_PlanFootprint.ts b/B05_Profile/logic/B05_Profile_PlanFootprint.ts index ffa4d7d95..b3ea9acfc 100644 --- a/B05_Profile/logic/B05_Profile_PlanFootprint.ts +++ b/B05_Profile/logic/B05_Profile_PlanFootprint.ts @@ -11,6 +11,8 @@ * 순수 계산 — 화면 · three · fetch · sessionStorage 없음 · 좌표 = 사업지 m · 오프셋 좌(+) · 우(-). * ========================================================================== */ +import type { PlanFootprint, Vertex } from "../../A00_Common/design/design_pipeline_types"; + /** 횡단 설계선 · 지반 표본 한 점(`CrossDesignEdge` · `CrossGroundSample` 꼴). */ export interface FootprintPoint { offset_m?: number | null; @@ -38,6 +40,8 @@ export interface FootprintStation { /** `slope_pieces` 의 측점 줄. */ export interface FootprintRecord { + /** 측점 열쇠(footprint 단계가 붙임 · 범위 밖 재사용) — 셈에는 안 씀. */ + key?: string; chainage_m: number; left_edge_m: number; left_end_m: number; @@ -53,7 +57,8 @@ export interface FootprintRecord { type Line = number[][]; -export interface PlanFootprint { +/** B07 `slope_pieces` 꼴(거울 시험 · 도면). */ +export interface SlopePieces { /** 도로 끝 [좌, 우]. */ edge: Line[]; /** 비탈 끝 — 쌓기(성토) 구간 · 깎기(절토) 구간. */ @@ -211,14 +216,20 @@ function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] { return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)]; } -/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */ -export function slopePieces( +interface Sampled { + lines: Record<(typeof KEYS)[number], Line>; + cut: Record; + ticks: Line[]; +} + +/** 측점 줄 → 측점 · 눈금 간격마다 네 선 · 쪽마다 깎기 여부 · 눈금. `slope_pieces` 의 몸통. */ +function sampleLines( route: Array<[number, number, number]>, input: FootprintRecord[], startM: number, endM: number, - stepM: number = TICK_STEP_M, -): PlanFootprint { + stepM: number, +): Sampled | null { const records = input .map((record) => ({ ...record, @@ -226,7 +237,7 @@ export function slopePieces( right_cut: Number(Boolean(record.right_cut)), })) .sort((a, b) => a.chainage_m - b.chainage_m) as unknown as Array>; - if (route.length < 2 || !records.length) return { edge: [], fill: [], cut: [], ticks: [] }; + if (route.length < 2 || !records.length) return null; const byChainage = new Map>(); for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record); const samples = new Set(); @@ -235,7 +246,7 @@ export function slopePieces( samples.add(roundTo(record.chainage_m, 3)); } for (let step = startM; step <= endM; step += stepM) samples.add(roundTo(step, 3)); - const lines: Record = Object.fromEntries(KEYS.map((key) => [key, []])); + const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"]; const cut: Record = { left: [], right: [] }; const ticks: Line[] = []; [...samples] @@ -264,6 +275,20 @@ export function slopePieces( ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]); } }); + return { lines, cut, ticks }; +} + +/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */ +export function slopePieces( + route: Array<[number, number, number]>, + input: FootprintRecord[], + startM: number, + endM: number, + stepM: number = TICK_STEP_M, +): SlopePieces { + const sampled = sampleLines(route, input, startM, endM, stepM); + if (!sampled) return { edge: [], fill: [], cut: [], ticks: [] }; + const { lines, cut, ticks } = sampled; const cutLines: Line[] = []; const fillLines: Line[] = []; for (const side of SIDES) { @@ -281,9 +306,43 @@ export function planFootprint( startM: number, endM: number, stepM: number = TICK_STEP_M, -): PlanFootprint { +): SlopePieces { const records = stations .map(footprintRecord) .filter((record): record is FootprintRecord => record !== null); return slopePieces(route, records, startM, endM, stepM); } + +/** 계약 `PlanFootprint` + 비탈 끝 점마다 깎기 여부 · 눈금 · 측점 줄(다음 계산이 범위 밖을 재사용). */ +export interface FootprintLines extends PlanFootprint { + slopeLeftCut: boolean[]; + slopeRightCut: boolean[]; + ticks: Line[]; + records: FootprintRecord[]; +} + +/** 측점 줄 → 계약 꼴 평면 발자국(시점 → 종점 · 선은 `slopePieces` 와 같은 점). */ +export function footprintLines( + route: Array<[number, number, number]>, + records: FootprintRecord[], + startM: number, + endM: number, + stepM: number = TICK_STEP_M, +): FootprintLines { + const sampled = sampleLines(route, records, startM, endM, stepM); + if (!sampled) { + const empty = { roadLeft: [], roadRight: [], slopeLeft: [], slopeRight: [] }; + return { ...empty, slopeLeftCut: [], slopeRightCut: [], ticks: [], records }; + } + const { lines, cut, ticks } = sampled; + return { + roadLeft: lines.left_edge_m as Vertex[], + roadRight: lines.right_edge_m as Vertex[], + slopeLeft: lines.left_end_m as Vertex[], + slopeRight: lines.right_end_m as Vertex[], + slopeLeftCut: cut.left, + slopeRightCut: cut.right, + ticks, + records, + }; +} diff --git a/resources/tester/test_346_2_footprint_stage.py b/resources/tester/test_346_2_footprint_stage.py new file mode 100644 index 000000000..4691b4804 --- /dev/null +++ b/resources/tester/test_346_2_footprint_stage.py @@ -0,0 +1,181 @@ +"""346-2 footprint 단계 — cross · ground → 계약 `PlanFootprint` 꼴 · 바뀐 범위 밖 재사용. + +./venv/Scripts/python.exe -m pytest resources/tester/test_346_2_footprint_stage.py -q +""" + +from __future__ import annotations + +import copy +import json +import re +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanSlope import ( # noqa: E402 + side_is_cut, + slope_end, +) + +LOGIC = ROOT / "B05_Profile" / "logic" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +OFFSETS = [0.5 * i for i in range(1, 41)] + +DRIVER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import { footprintStage } from "./B05_Profile_Footprint_Stage.js"; +const c = JSON.parse(readFileSync(process.argv[2], "utf-8")); +const ctx = (cross, range, prev) => ({ + generation: 1, range, signal: new AbortController().signal, inputs: {}, + out: { route: c.route, ground: { stations: c.stations, longitudinal: { chainageM: [], z: [] } }, + cross: { byKey: cross } }, + before: {}, prev, +}); +const first = footprintStage(ctx(c.cross, null, null)); +const second = footprintStage(ctx(c.cross2, c.range, first)); +const fresh = footprintStage(ctx(c.cross2, null, null)); +writeFileSync(process.argv[3], JSON.stringify({ first, second, fresh })); +""" + + +def _ground(o: float, k: int) -> float: + return 100.0 + (0.3 if k % 2 else -0.25) * o + + +def _detail(k: int, lift: float = 0.0) -> dict: + road = 100.0 + lift + line = [] + for o in [-20.0 + 0.5 * i for i in range(81)]: + if abs(o) <= 2.5: + line.append({"offset_m": o, "elevation_m": road}) + continue + g, edge_g = _ground(o, k), _ground(2.5 if o > 0 else -2.5, k) + run = abs(o) - 2.5 + z = min(g, road + run) if edge_g > road else max(g, road - run / 1.5) + line.append({"offset_m": o, "elevation_m": z}) + edge = {"offset_m": 2.5, "elevation_m": road} + return {"road_edges": {"left": edge, "right": {**edge, "offset_m": -2.5}}, "design_line": line} + + +def _case() -> dict: + stations, cross = [], {} + for k in range(6): + key = f"grid:{k}" + stations.append( + { + "key": key, + "chainageM": 20.0 * k, + "x": 20.0 * k, + "y": 0.0, + "azimuthDeg": 90.0, + "left": [0.0, 1.0], + "kind": "grid", + "id": None, + "ground": { + "centerZ": _ground(0.0, k), + "left": [{"offsetM": o, "z": _ground(o, k)} for o in OFFSETS], + # 우 = 중심에서 바깥 · offsetM 양수 · 한 칸 빈칸 + "right": [ + {"offsetM": o, "z": None if o == 15.0 else _ground(-o, k)} for o in OFFSETS + ], + }, + "planRadiusM": None, + "curveOuterSide": None, + "curveWideningM": None, + } + ) + cross[key] = {"key": key, "chainageM": 20.0 * k, "cutAreaM2": 0, "fillAreaM2": 0} + cross[key]["detail"] = _detail(k) + cross2 = copy.deepcopy(cross) + cross2["grid:1"]["detail"] = _detail(1, 2.0) # 범위 밖 — 앞 줄 그대로여야 함 + cross2["grid:4"]["detail"] = _detail(4, 2.0) # 범위 안 — 다시 셈 + route = {"line": [[0.0, 0.0], [100.0, 0.0]], "chainage": [0.0, 100.0], "lengthM": 100.0} + return { + "route": route, + "stations": stations, + "cross": cross, + "cross2": cross2, + "range": {"sources": ["grade"], "fromM": 75.0, "toM": 85.0, "keys": []}, + } + + +@pytest.fixture(scope="module") +def result(tmp_path_factory: pytest.TempPathFactory) -> dict: + out = tmp_path_factory.mktemp("ts_footprint_stage") + files = [ + str(LOGIC / "B05_Profile_Footprint_Stage.ts"), + str(LOGIC / "B05_Profile_PlanFootprint.ts"), + ] + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), *files, "--outDir", str(out), "--module", "esnext", "--target", "es2022", + "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", "--noResolve"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + stage = out / "B05_Profile_Footprint_Stage.js" + stage.write_text( + re.sub(r'from "\./B05_Profile_PlanFootprint"', 'from "./B05_Profile_PlanFootprint.js"', + stage.read_text(encoding="utf-8")), + encoding="utf-8", + ) # fmt: skip + (out / "driver.mjs").write_text(DRIVER, encoding="utf-8") + (out / "in.json").write_text(json.dumps(_case()), encoding="utf-8") + run = subprocess.run( # noqa: S603 + ["node", str(out / "driver.mjs"), str(out / "in.json"), str(out / "out.json")], + capture_output=True, text=True, cwd=str(out), + ) # fmt: skip + assert run.returncode == 0, run.stderr + return json.loads((out / "out.json").read_text(encoding="utf-8")) + + +def _by_key(lines: dict) -> dict: + return {record["key"]: record for record in lines["records"]} + + +def test_측점_줄은_B07_식과_같다(result) -> None: + """지반 꼴 바꿈(우 = 음수 오프셋 · 빈칸 = 무효)이 B07 slope_end · side_is_cut 입력과 같음.""" + case = _case() + got = _by_key(result["first"]) + for station in case["stations"]: + ground = station["ground"] + samples = ( + [ + {"offset_m": -s["offsetM"], "elevation_m": s["z"], "valid": s["z"] is not None} + for s in reversed(ground["right"]) + ] + + [{"offset_m": 0.0, "elevation_m": ground["centerZ"], "valid": True}] + + [{"offset_m": s["offsetM"], "elevation_m": s["z"], "valid": True} + for s in ground["left"]] + ) # fmt: skip + detail = case["cross"][station["key"]]["detail"] + record = got[station["key"]] + line = detail["design_line"] + assert record["left_end_m"] == pytest.approx(slope_end(line, samples, 2.5, "left")) + assert record["right_end_m"] == pytest.approx(slope_end(line, samples, -2.5, "right")) + assert record["left_cut"] == side_is_cut(samples, 2.5, 100.0) + assert record["right_cut"] == side_is_cut(samples, -2.5, 100.0) + + +def test_계약_꼴_선(result) -> None: + first = result["first"] + for name in ("roadLeft", "roadRight", "slopeLeft", "slopeRight"): + line = first[name] + assert len(line) == 51 # 0 ~ 100 m · 2 m + assert [p[0] for p in line] == sorted(p[0] for p in line) # 시점 → 종점 + assert all(p[1] == pytest.approx(2.5) for p in first["roadLeft"]) + assert all(p[1] == pytest.approx(-2.5) for p in first["roadRight"]) + assert len(first["slopeLeftCut"]) == 51 and len(first["slopeRightCut"]) == 51 + assert any(first["slopeLeftCut"]) and not all(first["slopeLeftCut"]) # 깎기 · 쌓기 섞임 + + +def test_범위_밖은_앞_줄_재사용(result) -> None: + first, second, fresh = (_by_key(result[k]) for k in ("first", "second", "fresh")) + assert second["grid:1"] == first["grid:1"] # 범위 밖 — 고친 횡단이어도 다시 안 셈 + assert second["grid:1"] != fresh["grid:1"] + assert second["grid:4"] == fresh["grid:4"] # 범위 안 — 새 값 + assert second["grid:4"] != first["grid:4"] diff --git a/resources/tester/test_346_2_plan_footprint_mirror.py b/resources/tester/test_346_2_plan_footprint_mirror.py index e1a26ef04..9be348692 100644 --- a/resources/tester/test_346_2_plan_footprint_mirror.py +++ b/resources/tester/test_346_2_plan_footprint_mirror.py @@ -142,7 +142,8 @@ def ts_result(tmp_path_factory: pytest.TempPathFactory) -> list[dict]: out = tmp_path_factory.mktemp("ts_footprint") subprocess.run( # noqa: S603 — 고정 실행 파일 ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--module", "esnext", - "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig"], + "--target", "es2022", "--moduleResolution", "bundler", "--ignoreConfig", "--noCheck", + "--noResolve"], cwd=str(ROOT), check=True, capture_output=True, ) # fmt: skip (out / "driver.mjs").write_text(DRIVER, encoding="utf-8")