Merge remote-tracking branch 'origin/dev' into sub_laptop_3

This commit is contained in:
2026-09-29 10:00:24 +09:00
9 changed files with 370 additions and 24 deletions
@@ -194,6 +194,9 @@ export function drawRidgeRing(
* 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다.
* -------------------------------------------------------------------------- */
/** 측점 라벨을 눈금에서 띄우는 거리(px) — 앞이 기본 자리, 뒤로 갈수록 비켜 놓는 자리. */
const LABEL_REACH_PX = [16, 32, 48, 64, 80];
export interface StationTickOptions {
/** 규칙 측점 간격(m). */
intervalM: number;
@@ -234,8 +237,8 @@ export function drawStationTicks(
context.font = "11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과
// 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다).
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다(2026-09-04 실측 4px) —
// 이미 그린 라벨과 겹치는 자리는 비켜 놓는다.
const drawn: Array<{ x: number; y: number; half: number }> = [];
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += interval) {
@@ -268,14 +271,33 @@ export function drawStationTicks(
context.stroke();
const label = stationLabel(chainage, interval);
const lx = sx + ux * side * 16;
const ly = sy + uy * side * 16;
const width = context.measureText(label).width + 6;
const half = width / 2;
const collides = drawn.some(
(item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16,
);
if (collides) continue;
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
// 찾는다. 끝내 못 찾으면 첫 자리에 그대로 둔다(눈금마다 이름이 있어야 한다).
const free = (x: number, y: number): boolean =>
!drawn.some((item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < 16);
let reach = LABEL_REACH_PX[0] * side;
for (const distance of LABEL_REACH_PX) {
const found = [side, -side].find((way) =>
free(sx + ux * way * distance, sy + uy * way * distance),
);
if (found !== undefined) {
reach = distance * found;
break;
}
}
const lx = sx + ux * reach;
const ly = sy + uy * reach;
if (Math.abs(reach) > LABEL_REACH_PX[0]) {
// 멀리 비켜 선 라벨은 어느 눈금 것인지 가는 선으로 잇는다.
context.beginPath();
context.moveTo(sx + ux * Math.sign(reach) * 6, sy + uy * Math.sign(reach) * 6);
context.lineTo(lx - ux * Math.sign(reach) * 8, ly - uy * Math.sign(reach) * 8);
context.lineWidth = 0.8;
context.strokeStyle = "rgba(40, 40, 40, 0.55)";
context.stroke();
}
drawn.push({ x: lx, y: ly, half });
context.save();
if (options.uprightRad) {
@@ -3,6 +3,9 @@
확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단
(cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고
각 측점에서 경로 접선의 좌우 방향으로 횡단을 샘플링한다.
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
"""
import math
+4 -3
View File
@@ -163,13 +163,13 @@ export async function openRouteEditModal(
min_radius_m: minRadiusM || 12,
station_interval_m: stationIntervalM,
}),
onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다.
});
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
/** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
const measure = createMeasureTool({
projectId,
stationIntervalM,
line: () => (plannedLine.length ? plannedLine : planned),
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }),
toScreen: (vertex) => toScreen(vertex),
isClosed: () => closed,
onChange: () => {
@@ -277,6 +277,7 @@ export async function openRouteEditModal(
dragging: dragNode >= 0 || dragHandle !== null,
}),
measure: measure.marks(),
crossWidth: crossPreview.watched(),
expected,
plannedLine,
planned,
@@ -21,6 +21,7 @@ import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_
import {
buildCrossSvg,
CROSS_PLOT_PAD,
designHalfWidths,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { formatStation } from "./B05_Profile_Util_Station";
@@ -35,6 +36,15 @@ export interface CrossPreviewParams {
min_radius_m: number;
station_interval_m: number;
};
/** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */
onShown?: () => void;
}
/** 위 판에 떠 있는 측점과 그 좌·우 설계 반폭(m) — 평면도가 반폭 선을 그리는 재료. */
export interface CrossWatch {
chainageM: number;
leftM: number;
rightM: number;
}
export interface CrossPreviewWindow {
@@ -42,6 +52,8 @@ export interface CrossPreviewWindow {
open: (chainageM: number) => Promise<void>;
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
/** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */
watched: () => CrossWatch | null;
}
interface CrossPane {
@@ -136,6 +148,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
shown = null;
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
}
params.onShown?.();
}
return {
@@ -150,5 +163,9 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
if (watching === null) return;
await load(watching, true);
},
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
// 반폭은 마지막으로 셈한 횡단 것이고, 노드를 놓으면 `refresh` 가 새 값으로 바꾼다.
watched: () =>
watching === null || !shown ? null : { chainageM: watching, ...designHalfWidths(shown) },
};
}
@@ -53,6 +53,18 @@ export function summarizeCross(preview: CrossPreviewResponse): string {
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`;
}
/** 좌·우 설계 반폭(m) — 사면이 원지반과 만나는 자리까지. 못 만난 측은 기본 반폭(샘플 끝). */
export function designHalfWidths(preview: CrossPreviewResponse): { leftM: number; rightM: number } {
const offsets = preview.samples
.map((sample) => Number(sample.offset_m))
.filter((offset) => Number.isFinite(offset));
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
return {
leftM: toes.left ?? Math.max(0, ...offsets), // +offset = 좌
rightM: Math.abs(toes.right ?? Math.min(0, ...offsets)), // −offset = 우
};
}
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
function asSection(preview: CrossPreviewResponse): CrossSection {
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
@@ -0,0 +1,77 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Frame.ts
* 측점 한 자리의 **평면 틀**(중심점 · 좌측 축) — 노선을 끄는 동안 브라우저가 바로 셈한다.
*
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at`
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
* ========================================================================== */
type Vertex = [number, number];
/** 접선을 재는 앞뒤 거리(m) — 파이썬 `max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5)` 와 같은 값. */
export const SECTION_LONG_SAMPLE_INTERVAL_M = 1.0;
export interface StationFrame {
origin: Vertex;
/** 좌측(+offset) 단위 벡터 — 우측은 반대 방향. */
left: Vertex;
}
/** 길이 0 인 선분을 걸러 낸 정점과 누가거리 — 파이썬 `_clean_polyline` 과 같은 거름. */
function cleanPolyline(line: ReadonlyArray<Vertex>): { points: Vertex[]; chainage: number[] } {
const points: Vertex[] = [];
const chainage: number[] = [];
line.forEach((vertex, index) => {
if (index > 0) {
const last = points[points.length - 1];
const step = Math.hypot(vertex[0] - last[0], vertex[1] - last[1]);
if (step <= 1e-8) return;
chainage.push(chainage[chainage.length - 1] + step);
} else {
chainage.push(0);
}
points.push([vertex[0], vertex[1]]);
});
return { points, chainage };
}
/** `np.interp` 와 같은 한 점 보간 — 범위 밖은 끝값에 붙는다. */
function interpolate(points: Vertex[], chainage: number[], target: number): Vertex {
const last = chainage.length - 1;
if (target <= chainage[0]) return points[0];
if (target >= chainage[last]) return points[last];
let index = 1;
while (chainage[index] < target) index += 1;
const span = chainage[index] - chainage[index - 1];
const ratio = span > 0 ? (target - chainage[index - 1]) / span : 0;
return [
points[index - 1][0] + (points[index][0] - points[index - 1][0]) * ratio,
points[index - 1][1] + (points[index][1] - points[index - 1][1]) * ratio,
];
}
/** 누가거리 `chainageM` 측점의 중심점과 좌측 축. 노선이 없거나 방향을 못 재면 null.
* 노선보다 긴 누가거리는 끝점에 붙인다 — 노선이 짧아지면 서버도 마지막 측점을 준다. */
export function stationFrameAt(
line: ReadonlyArray<Vertex>,
target: number,
probeM: number = Math.max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5),
): StationFrame | null {
const { points, chainage } = cleanPolyline(line);
if (points.length < 2) return null;
const total = chainage[chainage.length - 1];
const chainageM = Math.min(Math.max(target, 0), total);
let before = Math.max(0, chainageM - probeM);
let after = Math.min(total, chainageM + probeM);
if (after - before <= 1e-9) {
before = Math.max(0, chainageM - 1e-3);
after = Math.min(total, chainageM + 1e-3);
}
const from = interpolate(points, chainage, before);
const to = interpolate(points, chainage, after);
const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
if (length <= 1e-9) return null;
const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] };
}
+38 -10
View File
@@ -1,9 +1,10 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Measure.ts
* 계획노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤).
* 노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤).
*
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다.
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
* (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다.
*
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
@@ -16,10 +17,14 @@ import { formatStation } from "./B05_Profile_Util_Station";
type Vertex = [number, number];
/** 잰 노선 — 계획노선(실선) · 예상노선(점선). */
export type MeasureLine = "planned" | "expected";
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
const MEASURE_HIT_PX = 14;
interface MeasurePoint extends RoutePointHit {
line: MeasureLine;
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
z: number | null;
}
@@ -28,8 +33,8 @@ export interface MeasureToolParams {
projectId: string;
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
stationIntervalM: number;
/** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
line: () => Vertex[];
/** 지금 그려지는 두 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
lines: () => Record<MeasureLine, Vertex[]>;
toScreen: (vertex: Vertex) => [number, number];
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
isClosed: () => boolean;
@@ -39,8 +44,9 @@ export interface MeasureToolParams {
export interface MeasureMark {
point: Vertex;
/** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
/** 그 노선 시점에서 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
chainageM: number;
line: MeasureLine;
}
export interface MeasureTool {
@@ -56,6 +62,8 @@ export interface MeasureTool {
clear: () => void;
}
const LINE_NAME: Record<MeasureLine, string> = { planned: "", expected: "예상노선 " };
export interface MeasureControlsParams {
measure: MeasureTool;
/** 잰 값 작은 창·글·[거리 재기] 단추·작은 창 닫기 단추. */
@@ -105,13 +113,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
const hint = (): string => {
if (picked.length === 0) return "";
const first = picked[0];
const name = LINE_NAME[first.line];
if (picked.length === 1) {
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
return `${name}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
const second = picked[1];
const span = Math.abs(second.chainageM - first.chainageM);
const head =
`구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
`${name}구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (first.z === null || second.z === null || span <= 1e-6) {
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
@@ -124,8 +133,26 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
);
};
/** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */
const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => {
const lines = params.lines();
const hits = (["planned", "expected"] as const)
.map((line) => {
const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) return null;
const [sx, sy] = params.toScreen(hit.point);
return { ...hit, line, distance: Math.hypot(sx - px, sy - py) };
})
.filter((hit): hit is NonNullable<typeof hit> => hit !== null);
if (!hits.length) return null;
const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined;
const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a));
return { point: best.point, chainageM: best.chainageM, line: best.line };
};
return {
marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })),
marks: () =>
picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })),
hint,
active: () => picked.length > 0,
clear() {
@@ -134,13 +161,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
params.onChange();
},
async pick(px, py) {
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
const hit = hitAt(px, py);
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line);
picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
+51 -3
View File
@@ -23,6 +23,9 @@ import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
import { formatStation } from "./B05_Profile_Util_Station";
/** 노드 반지름(px). */
@@ -40,6 +43,8 @@ const CURVE_HANDLE_PX = 5;
const OUTWARD_PX = 26;
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
const MEASURE_COLOR = "#22c55e";
/** 횡단 반폭 선 색 — 노선(주황)·재기(초록)·등고선(연보라)과 겹치지 않는 하늘색. */
const CROSS_WIDTH_COLOR = "#0ea5e9";
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
@@ -81,8 +86,10 @@ export interface RouteEditScene {
picked: number;
/** 규칙 측점 간격(m). */
stationIntervalM: number;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */
measure: ReadonlyArray<{ point: Vertex; chainageM: number }>;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */
measure: ReadonlyArray<MeasureMark>;
/** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */
crossWidth: CrossWatch | null;
/** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */
rotationRad: number;
/** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */
@@ -253,6 +260,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
});
context.restore();
drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다.
drawStationMarks(context, scene, line);
scene.overStations?.(context);
drawMeasureMarks(context, scene, line);
@@ -303,7 +311,8 @@ function drawMeasureMarks(
if (scene.measure.length === 0) return;
context.save();
if (scene.measure.length >= 2) {
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
const measured = scene.measure[0].line === "expected" ? scene.expected : line;
const span = spanBetween(measured, scene.measure[0], scene.measure[1]);
if (span.length >= 2) {
context.strokeStyle = MEASURE_COLOR;
context.lineWidth = 4;
@@ -334,6 +343,45 @@ function drawMeasureMarks(
context.restore();
}
/**
* 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 그 측점 설계 반폭.
*
* 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버
* `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다.
*/
function drawCrossWidth(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
const watch = scene.crossWidth;
if (!watch) return;
const frame = stationFrameAt(line, watch.chainageM);
if (!frame) return;
const [ox, oy] = frame.origin;
const [lx, ly] = frame.left;
const ends: Vertex[] = [
[ox + lx * watch.leftM, oy + ly * watch.leftM],
[ox - lx * watch.rightM, oy - ly * watch.rightM],
];
const [cx, cy] = scene.toScreen(frame.origin);
context.save();
context.strokeStyle = CROSS_WIDTH_COLOR;
context.fillStyle = CROSS_WIDTH_COLOR;
context.lineWidth = 2.4;
ends.forEach((end) => {
const [x, y] = scene.toScreen(end);
context.beginPath();
context.moveTo(cx, cy);
context.lineTo(x, y);
context.stroke();
context.beginPath();
context.arc(x, y, 3, 0, Math.PI * 2);
context.fill();
});
context.restore();
}
/**
* 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕).
*
@@ -0,0 +1,138 @@
"""측점 평면 틀 거울 — 파이썬(`B05_Profile_Engine_Sections_Core`) ↔ TS(`_RouteEdit_Frame.ts`).
짝: `_interpolate_xy`·`_tangent_at`(측점 중심 · 좌측 축) ↔ `stationFrameAt`.
계획노선 편집 창이 노선을 끄는 동안 그리는 횡단 반폭 선이 [확인] 뒤 서버가 낸 측점 틀과
같은 자리 · 같은 방향이어야 함.
"""
from __future__ import annotations
import json
import math
import re
import subprocess
import sys
from pathlib import Path
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
from config.config_system import SECTION_LONG_SAMPLE_INTERVAL_M # noqa: E402
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_TOLERANCE = 1e-9
def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list:
return [
[cx + radius * math.cos(angle), cy + radius * math.sin(angle)]
for angle in np.linspace(start, end, count)
]
# 직선 · 꺾임 · 원호 · 겹친 정점 · 되꺾임 · 시종점 · 노선 밖 누가거리
CASES = [
{"line": [[0, 0], [100, 0]], "chainages": [0, 20, 40, 100]},
{"line": [[0, 0], [40, 0], [40, 0], [40, 30], [10, 70]], "chainages": [0, 20, 40, 60, 99.9]},
{"line": _arc(500, 500, 30, 0.2, 2.9, 41), "chainages": [0, 13.7, 20, 40, 60, 80]},
{"line": [[10, 10], [60, 12], [15, 14], [70, 40]], "chainages": [20, 50.5, 51, 120, 999]},
{"line": [[0, 0], [0.4, 0.3], [1.0, 0.1]], "chainages": [0, 0.5, 1.2]},
]
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import {
stationFrameAt,
SECTION_LONG_SAMPLE_INTERVAL_M,
} from "./B05_Profile_UI_RouteEdit_Frame.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = {
interval: SECTION_LONG_SAMPLE_INTERVAL_M,
frames: cases.map((item) => item.chainages.map((c) => stationFrameAt(item.line, c))),
};
writeFileSync(process.argv[3], JSON.stringify(out));
"""
def _python_frame(line: list, chainage: float) -> dict:
points, route_chainage = core._clean_polyline(np.asarray(line, dtype=np.float64))
total = float(route_chainage[-1])
target = min(float(chainage), total)
origin = core._interpolate_xy(points, route_chainage, np.array([chainage]))[0]
tangent = core._tangent_at(
points, route_chainage, target, max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5)
)
return {"origin": [float(origin[0]), float(origin[1])], "left": [-tangent[1], tangent[0]]}
def _ts_frames(tmp_path: Path) -> dict:
out = tmp_path / "js"
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,
)
for emitted in out.glob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\./[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
payload = tmp_path / "input.json"
result = tmp_path / "output.json"
payload.write_text(json.dumps(CASES), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(payload), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
def test_측점_틀이_두_쪽에서_같다(tmp_path: Path) -> None:
actual = _ts_frames(tmp_path)
assert actual["interval"] == SECTION_LONG_SAMPLE_INTERVAL_M
problems: list[str] = []
checked = 0
for index, case in enumerate(CASES):
for chainage, frame in zip(case["chainages"], actual["frames"][index], strict=True):
# 노선 밖 누가거리는 파이썬이 끝값으로 붙인다 — TS 도 같아야 함.
expected = _python_frame(case["line"], chainage)
assert frame is not None, f"사례 {index} · {chainage}m: TS 가 틀을 못 냄"
for key in ("origin", "left"):
for axis in (0, 1):
if abs(frame[key][axis] - expected[key][axis]) > _TOLERANCE:
problems.append(
f"사례 {index} · {chainage}m · {key}[{axis}]: "
f"{frame[key][axis]!r} vs {expected[key][axis]!r}"
)
checked += 1
assert checked >= 20
assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
def test_짝_머리글이_서로를_가리킨다() -> None:
assert "B05_Profile_Engine_Sections_Core.py" in TS_FILE.read_text(encoding="utf-8")
assert "B05_Profile_UI_RouteEdit_Frame.ts" in (core.__doc__ or "")