feat(B05 발자국): 346-2 평면 발자국 계산(순수) — B07 평면 비탈의 ① 짝

- B05_Profile/logic/B05_Profile_PlanFootprint.ts — 측점(중심 · 진행 방향) + 횡단 설계 결과 → 도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금
- 짝 = B07_DesignDetail_Engine_Cad_PlanSlope.py(route_frame · side_is_cut · slope_end · slope_pieces) · 머리에 짝 표시
- 거울 시험 test_346_2_plan_footprint_mirror.py · 그리기는 2파(sub2)

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:10:05 +09:00
co-authored by Claude Opus 5.5
parent 3d6c5584d1
commit aac091dacc
3 changed files with 477 additions and 0 deletions
@@ -0,0 +1,289 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_PlanFootprint.ts
* 평면 발자국 — 측점 횡단 설계 결과를 평면 선으로(도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금) (346-2)
*
* ⚠⚠ 짝: `B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanSlope.py`
* (`route_frame` · `side_is_cut` · `slope_end` · `slope_pieces`) — 한쪽만 고치면
* 종단 · 횡단 편집 중 평면과 B07 계획평면도가 갈린다.
* `footprintRecord` = `B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄.
* 거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
*
* 순수 계산 — 화면 · three · fetch · sessionStorage 없음 · 좌표 = 사업지 m · 오프셋 좌(+) · 우(-).
* ========================================================================== */
/** 횡단 설계선 · 지반 표본 한 점(`CrossDesignEdge` · `CrossGroundSample` 꼴). */
export interface FootprintPoint {
offset_m?: number | null;
elevation_m?: number | null;
valid?: boolean;
}
/** 측점 한 자리 — 중심 · 진행 방향 · 그 측점 횡단 설계 결과 · 지반 표본. */
export interface FootprintStation {
chainage_m: number;
x: number;
y: number;
/** 진행 방향(크기 무관) — 좌측 법선 = (−ty, tx). */
tx: number;
ty: number;
design: {
road_edges?: {
left?: FootprintPoint | null;
right?: FootprintPoint | null;
} | null;
design_line?: FootprintPoint[] | null;
} | null;
samples: FootprintPoint[];
}
/** `slope_pieces` 의 측점 줄. */
export interface FootprintRecord {
chainage_m: number;
left_edge_m: number;
left_end_m: number;
right_edge_m: number;
right_end_m: number;
left_cut?: boolean;
right_cut?: boolean;
x?: number;
y?: number;
nx?: number;
ny?: number;
}
type Line = number[][];
export interface PlanFootprint {
/** 도로 끝 [좌, 우]. */
edge: Line[];
/** 비탈 끝 — 쌓기(성토) 구간 · 깎기(절토) 구간. */
fill: Line[];
cut: Line[];
/** 비탈 눈금 — 도로 끝에서 비탈 끝으로(긴 · 짧은 번갈아). */
ticks: Line[];
}
/** 비탈 눈금 간격(1/1,200 실거리 m). 짝: `TICK_STEP_M`. */
export const TICK_STEP_M = 2.0;
type Side = "left" | "right";
const SIDES: Side[] = ["left", "right"];
const KEYS = ["left_edge_m", "left_end_m", "right_edge_m", "right_end_m"] as const;
/** 파이썬 `round(v, n)` 대용 — 반올림 경계(…5)만 다를 수 있음(1e-n 안). */
const roundTo = (value: number, digits: number): number => {
const scale = 10 ** digits;
return Math.round(value * scale) / scale;
};
function bisectLeft(values: number[], target: number): number {
let low = 0;
let high = values.length;
while (low < high) {
const middle = (low + high) >> 1;
if (values[middle] < target) low = middle + 1;
else high = middle;
}
return low;
}
/** 노선 [누가거리, x, y] 위 그 자리와 좌측 법선 [x, y, nx, ny]. 짝: `route_frame`. */
export function routeFrame(route: Array<[number, number, number]>, chainage: number): number[] {
const chainages = route.map((item) => item[0]);
const index = Math.min(Math.max(bisectLeft(chainages, chainage), 1), route.length - 1);
const [c0, x0, y0] = route[index - 1];
const [c1, x1, y1] = route[index];
const dx = x1 - x0;
const dy = y1 - y0;
const length = Math.hypot(dx, dy) || 1.0;
const ratio = c1 > c0 ? (chainage - c0) / (c1 - c0) : 0.0;
return [x0 + dx * ratio, y0 + dy * ratio, -dy / length, dx / length];
}
function interp(records: Array<Record<string, number>>, chainage: number, key: string): number {
const chainages = records.map((record) => record.chainage_m);
const index = bisectLeft(chainages, chainage);
if (index <= 0) return records[0][key];
if (index >= records.length) return records[records.length - 1][key];
const before = records[index - 1];
const after = records[index];
const span = after.chainage_m - before.chainage_m;
const ratio = span > 0 ? (chainage - before.chainage_m) / span : 0.0;
return before[key] + (after[key] - before[key]) * ratio;
}
function groundOf(samples: FootprintPoint[]): Array<[number, number]> {
return samples
.filter(
(item) =>
("valid" in item ? Boolean(item.valid) : true) && typeof item.elevation_m === "number",
)
.map((item): [number, number] => [Number(item.offset_m), Number(item.elevation_m)])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
}
function groundAt(ground: Array<[number, number]>, offset: number): number {
const offsets = ground.map(([x]) => x);
const index = Math.min(Math.max(bisectLeft(offsets, offset), 1), ground.length - 1);
const [x0, z0] = ground[index - 1] ?? ground[ground.length - 1];
const [x1, z1] = ground[index];
return z0 + (z1 - z0) * (x1 > x0 ? (offset - x0) / (x1 - x0) : 0.0);
}
/** 도로 끝에서 지반이 노면보다 높으면 그 쪽 비탈은 깎기. 짝: `side_is_cut`. */
export function sideIsCut(samples: FootprintPoint[], edge: number, elevation: number): boolean {
const ground = groundOf(samples);
return ground.length >= 2 && groundAt(ground, edge) > elevation;
}
/** 도로 끝에서 바깥으로 — 설계선이 원지반에 닿는 오프셋(비탈 끝). 짝: `slope_end`. */
export function slopeEnd(
designLine: FootprintPoint[],
samples: FootprintPoint[],
edge: number,
side: Side,
): number {
const ground = groundOf(samples);
const points = designLine
.map((p): [number, number] => [Number(p.offset_m), Number(p.elevation_m)])
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
const outward =
side === "left"
? points.filter((p) => p[0] > edge)
: [...points].reverse().filter((p) => p[0] < edge);
if (!ground.length) return outward.length ? outward[outward.length - 1][0] : edge;
let end = edge;
let differs = false;
for (const [offset, elevation] of outward) {
if (Math.abs(elevation - groundAt(ground, offset)) <= 1e-3) return differs ? offset : end;
differs = true;
end = offset;
}
return end;
}
/** 측점 한 자리 → `slope_pieces` 줄 · 도로 끝이 없으면 null. 짝: `slope_records` 한 줄. */
export function footprintRecord(station: FootprintStation): FootprintRecord | null {
const edges = station.design?.road_edges;
const left = Number(edges?.left?.offset_m);
const right = Number(edges?.right?.offset_m);
if (edges?.left?.offset_m == null || edges?.right?.offset_m == null) return null;
if (!Number.isFinite(left) || !Number.isFinite(right)) return null;
const line = station.design?.design_line ?? [];
const samples = station.samples ?? [];
const record: FootprintRecord = {
chainage_m: station.chainage_m,
left_edge_m: left,
left_end_m: slopeEnd(line, samples, left, "left"),
right_edge_m: right,
right_end_m: slopeEnd(line, samples, right, "right"),
};
for (const side of SIDES) {
const elevation = edges[side]?.elevation_m;
if (typeof elevation === "number") {
record[`${side}_cut`] = sideIsCut(samples, record[`${side}_edge_m`], elevation);
}
}
const length = Math.hypot(station.tx, station.ty) || 1.0;
record.x = station.x;
record.y = station.y;
record.nx = -station.ty / length;
record.ny = station.tx / length;
return record;
}
/** 한 선을 참 · 거짓 구간으로 끊음 — 바뀌는 자리 점은 앞뒤가 나눠 가져 이어짐. */
function splitRuns(points: Line, flags: boolean[]): [Line[], Line[]] {
const runs: { true: Line[]; false: Line[] } = { true: [], false: [] };
let current: Line = [];
let previous: boolean | null = null;
points.forEach((point, index) => {
const flag = flags[index];
if (flag !== previous) {
if (current.length) current.push(point);
current = [point];
runs[`${flag}`].push(current);
previous = flag;
} else {
current.push(point);
}
});
return [runs.true.filter((run) => run.length >= 2), runs.false.filter((run) => run.length >= 2)];
}
/** 측점 줄 → 도로 끝선 · 쌓기 · 깎기 비탈 끝선 · 눈금. 짝: `slope_pieces`. */
export function slopePieces(
route: Array<[number, number, number]>,
input: FootprintRecord[],
startM: number,
endM: number,
stepM: number = TICK_STEP_M,
): PlanFootprint {
const records = input
.map((record) => ({
...record,
left_cut: Number(Boolean(record.left_cut)),
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: [] };
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>();
for (const record of records) {
if (startM <= record.chainage_m && record.chainage_m <= endM)
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 cut: Record<Side, boolean[]> = { left: [], right: [] };
const ticks: Line[] = [];
[...samples]
.sort((a, b) => a - b)
.forEach((chainage, index) => {
const record = byChainage.get(chainage);
const [x, y, nx, ny] =
record && ["x", "y", "nx", "ny"].every((k) => record[k] !== undefined)
? [record.x, record.y, record.nx, record.ny]
: routeFrame(route, chainage);
const offsets = Object.fromEntries(KEYS.map((key) => [key, interp(records, chainage, key)]));
const points = Object.fromEntries(
KEYS.map((key) => [
key,
[roundTo(x + nx * offsets[key], 4), roundTo(y + ny * offsets[key], 4)],
]),
);
for (const key of KEYS) lines[key].push(points[key]);
for (const side of SIDES) cut[side].push(interp(records, chainage, `${side}_cut`) > 0.5);
// 눈금은 도로 끝에서 비탈 끝으로 — 긴 것 · 짧은 것이 번갈아(납품 도면 관례).
for (const side of SIDES) {
const edge = offsets[`${side}_edge_m`];
const end = offsets[`${side}_end_m`];
if (Math.abs(end - edge) < 0.05) continue;
const reach = index % 2 === 0 ? end : edge + (end - edge) / 2.0;
ticks.push([points[`${side}_edge_m`], [x + nx * reach, y + ny * reach]]);
}
});
const cutLines: Line[] = [];
const fillLines: Line[] = [];
for (const side of SIDES) {
const [cutRuns, fillRuns] = splitRuns(lines[`${side}_end_m`], cut[side]);
cutLines.push(...cutRuns);
fillLines.push(...fillRuns);
}
return { edge: [lines.left_edge_m, lines.right_edge_m], fill: fillLines, cut: cutLines, ticks };
}
/** 측점들 → 평면 발자국(도로 끝이 없는 측점은 뺌). */
export function planFootprint(
route: Array<[number, number, number]>,
stations: FootprintStation[],
startM: number,
endM: number,
stepM: number = TICK_STEP_M,
): PlanFootprint {
const records = stations
.map(footprintRecord)
.filter((record): record is FootprintRecord => record !== null);
return slopePieces(route, records, startM, endM, stepM);
}
@@ -1,6 +1,10 @@
"""B07 계획평면도 비탈 · 노선 따라가기 기하 — 노견선 · 성토 · 절토 외곽선 · 비탈 눈금(60 · 67장).
`_Engine_Cad_PlanKinds.py` 가 700줄을 넘어 떼어냄(141-1) — 파일 입출력 없음 · 좌표 = 사업지 m.
⚠⚠ 짝: `B05_Profile/logic/B05_Profile_PlanFootprint.ts`(route_frame · side_is_cut · slope_end ·
slope_pieces) — 종단 · 횡단 편집 중 평면 발자국 · 한쪽만 고치면 화면과 계획평면도가 갈림.
거울 시험: `resources/tester/test_346_2_plan_footprint_mirror.py`
"""
import math
@@ -0,0 +1,184 @@
"""346-2 평면 발자국 — TS(`B05_Profile/logic/B05_Profile_PlanFootprint.ts`) ↔ 파이썬
(`B07_DesignDetail_Engine_Cad_PlanSlope.py` · `slope_records` 한 줄) 거울 시험.
./venv/Scripts/python.exe -m pytest resources/tester/test_346_2_plan_footprint_mirror.py -q
"""
from __future__ import annotations
import json
import math
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
route_frame,
side_is_cut,
slope_end,
slope_pieces,
)
TS_FILE = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanFootprint.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
TOL = 2e-4 # 점 좌표 round(·, 4) — 반올림 경계만 1e-4 갈릴 수 있음
VERTICES = [(0.0, 0.0), (100.0, 0.0), (160.0, 60.0), (170.0, 140.0)]
def _route() -> list[tuple[float, float, float]]:
route, chain = [], 0.0
for index, (x, y) in enumerate(VERTICES):
if index:
chain += math.hypot(x - VERTICES[index - 1][0], y - VERTICES[index - 1][1])
route.append((chain, x, y))
return route
def _section(chainage: float) -> tuple[dict, list[dict]]:
"""측점 횡단 — 지반 기울기가 측점마다 바뀌어 깎기 · 쌓기가 뒤집힘 · 노면 ±2.5 m."""
tilt = 0.4 * math.sin(chainage / 37.0)
road = 100.0 + 0.6 * math.cos(chainage / 23.0)
offsets = [-20.0 + 0.5 * i for i in range(81)]
def ground(o: float) -> float:
return 100.0 + tilt * o + 0.3 * math.sin(o / 3.0)
samples = [
{"offset_m": o, "elevation_m": ground(o), "valid": abs(o) < 18.5 or chainage < 100}
for o in offsets
]
line = []
for o in offsets:
if abs(o) <= 2.5:
line.append({"offset_m": o, "elevation_m": road})
continue
run = abs(o) - 2.5
g = ground(o)
edge_g = ground(2.5 if o > 0 else -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})
design = {
"road_edges": {
"left": {"offset_m": 2.5, "elevation_m": road},
"right": {"offset_m": -2.5, "elevation_m": road},
},
"design_line": line,
}
return design, samples
def _stations() -> list[dict]:
route = _route()
total = route[-1][0]
stations = []
chainages = [20.0 * i for i in range(int(total // 20) + 1)] + [total, 60.0] # 겹친 측점 하나
for chainage in chainages:
x, y, nx, ny = route_frame(route, chainage)
design, samples = _section(chainage)
stations.append(
{
"chainage_m": chainage,
"x": x,
"y": y,
"tx": ny,
"ty": -nx,
"design": design,
"samples": samples,
}
)
stations.append({**stations[3], "chainage_m": 70.0, "design": {"road_edges": None}})
stations.append({**stations[4], "chainage_m": 90.0, "samples": []}) # 지반 없음
return stations
def _py_record(station: dict) -> dict | None:
"""`B07_DesignDetail_Router_Support_Plan.slope_records` 의 측점 한 줄(파일 읽기 뺌)."""
design = station["design"] or {}
edges = design.get("road_edges") or {}
try:
left_edge = float(edges["left"]["offset_m"])
right_edge = float(edges["right"]["offset_m"])
except (KeyError, TypeError, ValueError):
return None
line, samples = design.get("design_line") or [], station["samples"] or []
record = {
"chainage_m": float(station["chainage_m"]),
"left_edge_m": left_edge,
"left_end_m": slope_end(line, samples, left_edge, "left"),
"right_edge_m": right_edge,
"right_end_m": slope_end(line, samples, right_edge, "right"),
}
for side in ("left", "right"):
elevation = edges[side].get("elevation_m")
if isinstance(elevation, (int, float)):
record[f"{side}_cut"] = side_is_cut(samples, record[f"{side}_edge_m"], elevation)
length = math.hypot(station["tx"], station["ty"]) or 1.0
record.update(
x=station["x"], y=station["y"], nx=-station["ty"] / length, ny=station["tx"] / length
)
return record
DRIVER = """
import { readFileSync, writeFileSync } from "node:fs";
import { planFootprint } from "./B05_Profile_PlanFootprint.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf-8"));
writeFileSync(process.argv[3], JSON.stringify(cases.map((c) =>
planFootprint(c.route, c.stations, c.start, c.end, c.step))));
"""
CASES = [(0.0, 400.0, 2.0), (35.0, 130.0, 2.0), (0.0, 400.0, 10.0)]
@pytest.fixture(scope="module")
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"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
(out / "driver.mjs").write_text(DRIVER, encoding="utf-8")
payload = [
{"route": _route(), "stations": _stations(), "start": s, "end": e, "step": t}
for s, e, t in CASES
]
(out / "in.json").write_text(json.dumps(payload), 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 _same_lines(want: list, got: list, name: str) -> None:
assert len(want) == len(got), f"{name} 선 수"
for line_w, line_g in zip(want, got):
assert len(line_w) == len(line_g), f"{name} 점 수"
for pw, pg in zip(line_w, line_g):
assert abs(pw[0] - pg[0]) < TOL and abs(pw[1] - pg[1]) < TOL, f"{name} {pw} {pg}"
@pytest.mark.parametrize("index", range(len(CASES)))
def test_발자국이_두_쪽에서_같다(ts_result, index: int) -> None:
start, end, step = CASES[index]
records = [r for r in map(_py_record, _stations()) if r is not None]
edge, fill, cut, ticks = slope_pieces(_route(), records, start, end, step)
got = ts_result[index]
_same_lines(edge["lines"], got["edge"], "도로 끝")
_same_lines(fill["lines"], got["fill"], "쌓기")
_same_lines(cut["lines"], got["cut"], "깎기")
_same_lines(ticks["ticks"], got["ticks"], "눈금")
def test_깎기_쌓기가_함께_나옴(ts_result) -> None:
got = ts_result[0]
assert len(got["edge"]) == 2 and got["fill"] and got["cut"] and got["ticks"]