feat(B05): 계획노선 편집 — 예상노선 거리 재기 · 측점 눈금 라벨 비켜 놓기 · 횡단 반폭 선
- 거리·기울기 재기가 예상노선(점선)에서도 됨 — 두 점은 한 노선 위 · 다른 노선을 짚으면 새로 시작 - 측점 눈금 라벨이 겹치면 빼지 않고 비켜 놓음(반대쪽 → 한 칸 더 멀리 · 가는 선으로 이음) — 공용 drawStationTicks - 오른쪽에 횡단이 뜬 측점에 좌·우 설계 반폭 선 — 노드를 끄는 동안 브라우저 셈으로 바로 따라감 - 측점 틀 파이썬·TS 짝(_interpolate_xy·_tangent_at ↔ stationFrameAt) + 거울 시험 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014WeXB1zh5hbYLgoeGS5kQu
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -47,7 +47,7 @@ import {
|
||||
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
|
||||
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
|
||||
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { bindMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import {
|
||||
applyArcLocks,
|
||||
@@ -100,7 +100,7 @@ export async function openRouteEditModal(
|
||||
): Promise<void> {
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
const chrome = createRouteEditChrome();
|
||||
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
|
||||
const { overlay, canvas, status, busy } = chrome;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
@@ -151,45 +151,22 @@ export async function openRouteEditModal(
|
||||
min_radius_m: minRadiusM || 12,
|
||||
station_interval_m: stationIntervalM,
|
||||
}),
|
||||
onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다.
|
||||
});
|
||||
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = createMeasureTool({
|
||||
/** 구간 재기 — 계획·예상 노선 위 두 점. 셈·배선은 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = bindMeasureTool({
|
||||
...chrome,
|
||||
projectId,
|
||||
stationIntervalM,
|
||||
line: () => (plannedLine.length ? plannedLine : planned),
|
||||
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
|
||||
lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }),
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
isClosed: () => closed,
|
||||
onChange: () => {
|
||||
syncMeasureBox();
|
||||
draw();
|
||||
},
|
||||
});
|
||||
|
||||
/** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */
|
||||
function syncMeasureBox(): void {
|
||||
const on = measure.active();
|
||||
measureBox.hidden = !on;
|
||||
measureText.textContent = measure.hint();
|
||||
if (on && picked >= 0) {
|
||||
onShow: () => {
|
||||
if (picked < 0) return;
|
||||
picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다.
|
||||
syncCurveBar();
|
||||
}
|
||||
}
|
||||
|
||||
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
|
||||
let measureMode = false;
|
||||
measureButton.addEventListener("click", () => {
|
||||
measureMode = !measureMode;
|
||||
measureButton.classList.toggle("is-active", measureMode);
|
||||
if (!measureMode) measure.clear();
|
||||
});
|
||||
overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => {
|
||||
measure.clear(); // 닫으면 잰 것이 지워진다(㉔).
|
||||
measureMode = false;
|
||||
measureButton.classList.remove("is-active");
|
||||
syncMeasureBox();
|
||||
draw();
|
||||
},
|
||||
draw: () => draw(),
|
||||
});
|
||||
let view: ViewState = {
|
||||
width: 0,
|
||||
@@ -282,6 +259,7 @@ export async function openRouteEditModal(
|
||||
rotationRad: rotation.radians(),
|
||||
uprightRad: rotation.uprightRad(),
|
||||
measure: measure.marks(),
|
||||
crossWidth: crossPreview.watched(),
|
||||
expected,
|
||||
plannedLine,
|
||||
planned,
|
||||
@@ -443,7 +421,7 @@ export async function openRouteEditModal(
|
||||
if (event.button !== 0) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (event.shiftKey || measureMode) {
|
||||
if (event.shiftKey || measure.mode()) {
|
||||
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
|
||||
void measure.pick(px, py);
|
||||
return;
|
||||
|
||||
@@ -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]] };
|
||||
}
|
||||
@@ -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 function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
|
||||
let picked: MeasurePoint[] = [];
|
||||
@@ -63,13 +71,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} · 지반고를 못 읽어 기울기는 못 냅니다.`;
|
||||
@@ -82,8 +91,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() {
|
||||
@@ -92,13 +119,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;
|
||||
@@ -118,3 +146,48 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MeasureBinding extends MeasureTool {
|
||||
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
|
||||
mode: () => boolean;
|
||||
}
|
||||
|
||||
/** 재기 도구와 단추·작은 창 배선. 작은 창이 뜨면 `onShow` — 곡선 패널과 같이 뜨지 않게(㉔). */
|
||||
export function bindMeasureTool(
|
||||
params: Omit<MeasureToolParams, "onChange"> & {
|
||||
overlay: HTMLElement;
|
||||
measureBox: HTMLElement;
|
||||
measureText: HTMLElement;
|
||||
measureButton: HTMLButtonElement;
|
||||
onShow: () => void;
|
||||
draw: () => void;
|
||||
},
|
||||
): MeasureBinding {
|
||||
let measureMode = false;
|
||||
const sync = (): void => {
|
||||
const on = tool.active();
|
||||
params.measureBox.hidden = !on;
|
||||
params.measureText.textContent = tool.hint();
|
||||
if (on) params.onShow();
|
||||
};
|
||||
const tool = createMeasureTool({
|
||||
...params,
|
||||
onChange: () => {
|
||||
sync();
|
||||
params.draw();
|
||||
},
|
||||
});
|
||||
params.measureButton.addEventListener("click", () => {
|
||||
measureMode = !measureMode;
|
||||
params.measureButton.classList.toggle("is-active", measureMode);
|
||||
if (!measureMode) tool.clear();
|
||||
});
|
||||
params.overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => {
|
||||
tool.clear(); // 닫으면 잰 것이 지워진다(㉔).
|
||||
measureMode = false;
|
||||
params.measureButton.classList.remove("is-active");
|
||||
sync();
|
||||
params.draw();
|
||||
});
|
||||
return { ...tool, mode: () => measureMode };
|
||||
}
|
||||
|
||||
@@ -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 ㉚). */
|
||||
@@ -240,6 +247,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
});
|
||||
context.restore();
|
||||
|
||||
drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다.
|
||||
drawStationMarks(context, scene, line);
|
||||
drawMeasureMarks(context, scene, line);
|
||||
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
|
||||
@@ -289,7 +297,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;
|
||||
@@ -320,6 +329,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 "")
|
||||
Reference in New Issue
Block a user