diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index c26e2d56..b0f8d913 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -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) { diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 73b67199..707c6694 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -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 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 5f33de25..ba924f94 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -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 { 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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 4117ec05..2ed105aa 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -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; /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; + /** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 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) }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts index b99f5d44..5d06c97c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -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; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts new file mode 100644 index 00000000..f7e1f4b5 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Frame.ts @@ -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): { 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, + 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]] }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index 6ec35fb9..2e0e4f8a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -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; 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 = { 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 => 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 & { + 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 }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 023cd01c..65d1c231 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -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): 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; + /** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 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, +): 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 ㉕). * diff --git a/resources/tester/test_b05_routeedit_frame_mirror.py b/resources/tester/test_b05_routeedit_frame_mirror.py new file mode 100644 index 00000000..8b4d1182 --- /dev/null +++ b/resources/tester/test_b05_routeedit_frame_mirror.py @@ -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 "")