Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1

This commit is contained in:
2026-10-16 11:38:38 +09:00
9 changed files with 413 additions and 23 deletions
+2 -2
View File
@@ -73,7 +73,7 @@ export function designTerrainGridNow(projectId: string): DesignTerrainGrid | nul
/** 한 번에 물을 수 있는 점 수 — 서버 `MAX_POINTS` 와 같은 값. */
const ELEVATION_CHUNK = 4000;
/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길 값은 mm 자리(응답 그대로). */
/** 계산 묶음 ground 단계에 넣을 격자 · 서버 길(PLAN 346-1). 서버 길도 소수 6자리 — 격자 길과 같은 값. */
export function groundStageDeps(projectId: string): Pick<GroundStageDeps, "grid" | "remote"> {
return {
grid: () => designTerrainGridNow(projectId),
@@ -88,7 +88,7 @@ export function groundStageDeps(projectId: string): Pick<GroundStageDeps, "grid"
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ points: chunk, terrain }),
body: JSON.stringify({ points: chunk, terrain, decimals: 6 }),
signal,
});
if (!response.ok) throw new Error(`지반고 요청 실패: HTTP ${response.status}`);
+5 -1
View File
@@ -62,6 +62,8 @@ class ElevationRequest(BaseModel):
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
terrain: AnalysisTerrain | None = None
#: 표고 자릿수 — 화면 값은 mm(3) · 계산 묶음 서버 길은 6(격자 표본 · 서버 생성과 같은 자리).
decimals: int = Field(3, ge=0, le=6)
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
@@ -112,7 +114,9 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) ->
return {
"status": "success",
"project_id": str(project_id),
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
"z": [
None if not ok else round(float(value), request.decimals) for value, ok in zip(z, valid)
],
"valid": [bool(ok) for ok in valid],
}
@@ -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);
}
@@ -13,6 +13,7 @@
* ========================================================================== */
import type {
DesignCurve,
GroundOutput,
GroundSample,
PipelineStation,
@@ -38,35 +39,43 @@ import {
export interface RouteStageDeps {
/** 곡선 기본 반지름(m) — 편집기 `minRadiusM`(계획 기준 · 0 = 없음). */
minRadiusM(): number;
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 — 없으면 null. */
savedLine(): Vertex[] | null;
/** 초안이 없을 때(= 저장분 그대로) 그릴 노선 · 곡선표 — 없으면 null. */
savedLine(): { line: Vertex[]; curves: DesignCurve[] } | null;
}
/** 정점마다 누가거리 — 첫 점 0. */
export function routeLineOf(line: Vertex[]): RouteLine {
export function routeLineOf(line: Vertex[], curves: DesignCurve[] = []): RouteLine {
const chainage = [0];
for (let i = 1; i < line.length; i += 1) {
chainage.push(
chainage[i - 1] + Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]),
);
}
return { line, chainage, lengthM: chainage[chainage.length - 1] };
return { line, curves, chainage, lengthM: chainage[chainage.length - 1] };
}
/** 초안 → 그린 노선. 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기 `markEdited` 와 같은 차례). */
export function draftLine(draft: RouteDraft, minRadiusM: number): Vertex[] {
/** 초안 → 그린 노선 · 곡선표(계약 꼴). 길이를 붙든 곡선은 교각에 맞춰 R 을 다시 잡음(편집기
* `markEdited` 와 같은 차례). */
export function draftLine(
draft: RouteDraft,
minRadiusM: number,
): { line: Vertex[]; curves: DesignCurve[] } {
const nodes = draft.nodes.map((node): Vertex => [node[0], node[1]]);
const radius = [...draft.curveRadius];
applyArcLocks(nodes, [...draft.curveLock], [...draft.curveArc], radius);
return buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM).vertices;
const built = buildEditedPolyline(nodes, draft.curveOn, radius, minRadiusM);
return {
line: built.vertices,
curves: built.curves.map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m })),
};
}
export function createRouteStage(deps: RouteStageDeps): StageFn<"route"> {
return (ctx) => {
const draft = ctx.inputs.route;
const line = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
if (!line || line.length < 2) throw new Error("그릴 노선이 없습니다.");
return routeLineOf(line);
const drawn = draft ? draftLine(draft, deps.minRadiusM()) : deps.savedLine();
if (!drawn || drawn.line.length < 2) throw new Error("그릴 노선이 없습니다.");
return routeLineOf(drawn.line, drawn.curves);
};
}
+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,
};
}
@@ -114,3 +114,30 @@ def test_받을_자리_서명은_manifest_구성과_평활(tmp_path):
)
assert terrain_router._design_grid({**PARAMS, "method": "tin"}, models, confirmed) is None
assert terrain_router._design_grid(PARAMS, models, None) is None
def test_지반고_자릿수_기본_3_묶음은_6(tmp_path, monkeypatch):
root = tmp_path
models = root / "B04_PreProcess" / "models"
models.mkdir(parents=True)
x = np.arange(0.0, 11.0)
np.savez(
models / "dtm_csf_smooth.npz",
x=x,
y=x,
z=np.add.outer(x * 0.0, x * 0.1234567),
valid_mask=np.ones((11, 11), dtype=bool),
)
_patch(monkeypatch, root, None)
async def params(_connection, _project_id):
return dict(PARAMS)
monkeypatch.setattr(terrain_router, "get_surface_confirmation_params", params)
ask = terrain_router.ElevationRequest
plain = asyncio.run(terrain_router.read_route_elevations(PID, ask(points=[(5.5, 5.0)])))
fine = asyncio.run(
terrain_router.read_route_elevations(PID, ask(points=[(5.5, 5.0)], decimals=6))
)
assert plain["z"] == [round(5.5 * 0.1234567, 3)]
assert fine["z"] == [round(5.5 * 0.1234567, 6)]
@@ -49,7 +49,9 @@ const draft = {
rev: 1, nodes: job.nodes, curveOn: Array(n).fill(true), curveRadius: Array(n).fill(null),
curveLock: Array(n).fill(null), curveArc: Array(n).fill(null), stationSnap: false, terrain: null,
};
const routeStage = createRouteStage({ minRadiusM: () => job.minRadius, savedLine: () => job.line });
const routeStage = createRouteStage({
minRadiusM: () => job.minRadius, savedLine: () => ({ line: job.line, curves: [] }),
});
const ctxOf = (extra) => ({
generation: 1, range: null, signal: new AbortController().signal,
inputs: { route: null, alignment: null, cross: null, pipes: null, structures: null },
@@ -242,7 +244,14 @@ def test_route_ground_단계가_서버와_같고_범위_밖을_재사용한다(t
steps = np.hypot(*np.diff(np.asarray(built.vertices), axis=0).T)
assert abs(route["lengthM"] - float(steps.sum())) < 1e-6
assert route["chainage"][0] == 0 and len(route["chainage"]) == len(route["line"])
assert len(route["curves"]) == len(built.curves) > 0
for ts, py in zip(route["curves"], built.curves, strict=True):
assert set(ts) == {"start", "apex", "end", "radius_m"}
assert abs(ts["radius_m"] - py.radius_m) < 1e-9
for key in ("start", "apex", "end"):
assert max(abs(a - b) for a, b in zip(ts[key], getattr(py, key))) < 1e-9
assert out["saved"]["line"] == line
assert out["saved"]["curves"] == []
assert out["noRoute"]
# ground 브라우저 길 = 서버 generate_sections(측점 · 횡단 · 종단)
@@ -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")