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:
2026-10-16 11:30:16 +09:00
co-authored by Claude Opus 5.5
parent 70ad119cf3
commit 50620a47b8
4 changed files with 350 additions and 9 deletions
@@ -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);
}
+67 -8
View File
@@ -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")