feat(B05 발자국): 346-2 footprint 단계 — cross · ground → 계약 PlanFootprint 꼴 · 범위 밖 재사용
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
@@ -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<string, FootprintRecord>();
|
||||
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);
|
||||
}
|
||||
@@ -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<Side, boolean[]>;
|
||||
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<Record<string, number>>;
|
||||
if (route.length < 2 || !records.length) return { edge: [], fill: [], cut: [], ticks: [] };
|
||||
if (route.length < 2 || !records.length) return null;
|
||||
const byChainage = new Map<number, Record<string, number>>();
|
||||
for (const record of records) byChainage.set(roundTo(record.chainage_m, 3), record);
|
||||
const samples = new Set<number>();
|
||||
@@ -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<string, Line> = Object.fromEntries(KEYS.map((key) => [key, []]));
|
||||
const lines = Object.fromEntries(KEYS.map((key) => [key, []])) as unknown as Sampled["lines"];
|
||||
const cut: Record<Side, boolean[]> = { 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user