From a0a29fd0022bc6274aed2ad14c1070b7f4a657a2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 17 Oct 2026 17:16:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(B05=20=ED=8F=89=EB=A9=B4):=20=EB=A7=B4?= =?UTF-8?q?=EB=8F=94=20=3D=20=EA=B3=A0=EB=A6=84=2020=20m=20=EB=B2=95?= =?UTF-8?q?=EC=84=A0=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EA=B3=A1=EC=84=A0=20?= =?UTF-8?q?3=EC=A0=90=20=C2=B7=20=EA=B3=A1=EC=84=A0=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20=EA=B5=90=EA=B0=81=EC=A0=90=20=EC=82=BC=EA=B0=81=ED=98=95=20?= =?UTF-8?q?=C2=B7=20=EC=9E=A0=EA=B8=88=20=ED=99=A9=EA=B8=88=20(391)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 391-1 측점 맴돔 · 고름 = 한 함수(drawStationNormal) · 좌우 20 m 고정 하늘색 · 짧은 12 px 선 · 절성토 색 지움 · 구조물 마름모 그대로 - 391-2 고른 곡선 = 굵은 호 + 교각점→BC · EC 접선 + BC · MC · EC 세 점(새 파일 RouteEdit_Curve_Pick · MC = curveMidVertex) · 곡선 없는 교각점 = 같은 크기 삼각형 - 391-3 잠금 = 황금(#ca8a04 · 어두움 #facc15) · 굵기 4 + 흰 테 - 시험 test_391_routeedit_plan_draw 새로 · test_381_3 맴돔 배선 고쳐 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../B05_Profile_UI_RouteEdit_Curve_Pick.ts | 74 +++++++++++ B05_Profile/B05_Profile_UI_RouteEdit_Hover.ts | 73 +++++++---- .../B05_Profile_UI_RouteEdit_Render.ts | 109 ++++++---------- .../logic/B05_Profile_UI_RouteEdit_Curve.ts | 17 +++ .../tester/helper_391_routeedit_plan_draw.cjs | 117 ++++++++++++++++++ .../tester/test_381_3_routeedit_node_pick.py | 7 +- .../tester/test_391_routeedit_plan_draw.py | 93 ++++++++++++++ ui_template/ui_template_theme.css | 6 +- 8 files changed, 397 insertions(+), 99 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Curve_Pick.ts create mode 100644 resources/tester/helper_391_routeedit_plan_draw.cjs create mode 100644 resources/tester/test_391_routeedit_plan_draw.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Curve_Pick.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Curve_Pick.ts new file mode 100644 index 000000000..d7876bcf0 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Curve_Pick.ts @@ -0,0 +1,74 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Curve_Pick.ts + * 노선 편집 평면 **고른 곡선 밝힘 · 곡선 없는 교각점 삼각형**(요구 242-12 · 13 · PLAN 391-2). + * + * 고른 곡선(교각점 고름 · 호 누름 = 같은 `picked`) = 호 굵게 + 교각점→BC · EC 접선 + BC · MC · EC + * 세 점(하늘색). `_Render` 가 노드 · 손잡이 뒤 맨 위에서 부름 — 집기(`_Input`)는 그대로. + * ========================================================================== */ + +import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; +import { curveArcVertices, curveMidVertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; +import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; + +/** 고름 색 — 측점 법선(`_Hover`)과 같은 하늘색. */ +const PICK_COLOR = "#0ea5e9"; +/** 삼각형 외접 반지름 = 노드 반지름 × 이 값 — 원과 넓이가 비슷해 보이게. */ +const TRIANGLE_SCALE = 1.4; + +/** 곡선 없는 교각점 삼각형(391-2) — 꼭짓점 위 · 원 대신 같은 자리 · 크기. 경로만 그림. */ +export function traceNodeTriangle( + context: CanvasRenderingContext2D, + x: number, + y: number, + radius: number, +): void { + const r = radius * TRIANGLE_SCALE; + context.moveTo(x, y - r); + context.lineTo(x + r * Math.cos(Math.PI / 6), y + r / 2); + context.lineTo(x - r * Math.cos(Math.PI / 6), y + r / 2); + context.closePath(); +} + +/** 고른 곡선 밝힘(391-2) — 곡선을 지운 교각점 · 고름 없음이면 안 그림. */ +export function drawPickedCurve( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + style: CSSStyleDeclaration, +): void { + const curve = scene.curveInfo.find((item) => item.node_first === scene.picked); + if (!curve || scene.curveOn[curve.node_first] === false) return; + const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + const path = (points: ReadonlyArray): void => { + context.beginPath(); + points.forEach((vertex, index) => { + const [x, y] = scene.toScreen(vertex); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + }; + context.save(); + context.strokeStyle = PICK_COLOR; + context.lineWidth = 2; + context.setLineDash([6, 4]); + path([curve.start, curve.apex, curve.end]); + context.stroke(); + context.setLineDash([]); + path(curveArcVertices(curve)); + context.strokeStyle = halo; + context.lineWidth = 8; + context.stroke(); + context.strokeStyle = PICK_COLOR; + context.lineWidth = 5; + context.stroke(); + context.fillStyle = PICK_COLOR; + context.strokeStyle = halo; + context.lineWidth = 2; + [curve.start, curveMidVertex(curve), curve.end].forEach((point) => { + const [x, y] = scene.toScreen(point); + context.beginPath(); + context.arc(x, y, 5, 0, Math.PI * 2); + context.fill(); + context.stroke(); + }); + context.restore(); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Hover.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Hover.ts index 6fabb93f3..657e53c44 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Hover.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Hover.ts @@ -3,7 +3,8 @@ * 노선 편집 평면 **맴돔 밝힘**(요구 241-14 · PLAN 381-3). * * 맴도는 자리는 `_Pointer` 가 `_Input.pickAtScreen`(집기와 같은 셈)으로 적고, 여기서 이길 쪽만 - * 그린다 — 노드 = 바깥 고리 · 횡단 눈금 = 파란 짧은 직각선. `_Render` 가 노드 그린 뒤 부름. + * 그린다 — 노드 = 바깥 고리 · 횡단 눈금 = 고른 측점과 같은 좌우 20 m 법선(`drawStationNormal` · + * 391-1). `_Render` 가 노드 그린 뒤 부름. * ========================================================================== */ import { pickHover } from "./B05_Profile_UI_RouteEdit_Input"; @@ -11,10 +12,52 @@ import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; -/** 횡단 눈금 맴돔 색 — 횡단 반폭 선(`_Render`)과 같은 하늘색. */ -const STATION_HOVER_COLOR = "#0ea5e9"; +/** 측점 법선 색 — 노선(주황)·재기(초록)·등고선(연보라)과 겹치지 않는 하늘색. */ +const STATION_NORMAL_COLOR = "#0ea5e9"; +/** 법선 좌우 길이(m) — 서버 `SECTION_CROSS_HALF_WIDTH_M` 과 같은 값 · 고정(391-1). */ +const STATION_NORMAL_HALF_M = 20; -/** 맴도는 자리 밝힘(381-3) — 누르면 집힐 쪽만: 노드 = 바깥 고리 · 횡단 눈금 = 파란 짧은 직각선. */ +/** 측점 법선(391-1) — 맴돔 · 고름 한 그림: 측점에서 노선에 직각 좌우 20 m · 끝 점 · 측점 고리. + * 방향은 지금 그려진 노선으로 매 프레임 다시 짚음(`stationFrameAt`) — 끄는 동안 바로 따라감. */ +export function drawStationNormal( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, + chainageM: number, +): void { + const frame = stationFrameAt(line, chainageM); + if (!frame) return; + const [ox, oy] = frame.origin; + const [lx, ly] = frame.left; + const half = STATION_NORMAL_HALF_M; + const [cx, cy] = scene.toScreen(frame.origin); + const [ax, ay] = scene.toScreen([ox + lx * half, oy + ly * half]); + const [bx, by] = scene.toScreen([ox - lx * half, oy - ly * half]); + context.save(); + context.setLineDash([]); + context.strokeStyle = STATION_NORMAL_COLOR; + context.fillStyle = STATION_NORMAL_COLOR; + context.lineWidth = 2.4; + context.beginPath(); + context.moveTo(ax, ay); + context.lineTo(bx, by); + context.stroke(); + for (const [x, y] of [ + [ax, ay], + [bx, by], + ]) { + context.beginPath(); + context.arc(x, y, 3, 0, Math.PI * 2); + context.fill(); + } + context.lineWidth = 2; + context.beginPath(); + context.arc(cx, cy, 6, 0, Math.PI * 2); + context.stroke(); + context.restore(); +} + +/** 맴도는 자리 밝힘(381-3) — 누르면 집힐 쪽만: 노드 = 바깥 고리 · 횡단 눈금 = 측점 법선. */ export function drawPickHover( context: CanvasRenderingContext2D, scene: RouteEditScene, @@ -41,27 +84,7 @@ export function drawPickHover( context.stroke(); } } else { - const frame = stationFrameAt(line, hover.chainage); - if (frame) { - const [ax, ay] = scene.toScreen(frame.origin); - const [bx, by] = scene.toScreen([ - frame.origin[0] + frame.left[0], - frame.origin[1] + frame.left[1], - ]); - const length = Math.hypot(bx - ax, by - ay) || 1; - const ux = ((bx - ax) / length) * 12; - const uy = ((by - ay) / length) * 12; - context.strokeStyle = STATION_HOVER_COLOR; - context.fillStyle = STATION_HOVER_COLOR; - context.lineWidth = 3; - context.beginPath(); - context.moveTo(ax - ux, ay - uy); - context.lineTo(ax + ux, ay + uy); - context.stroke(); - context.beginPath(); - context.arc(ax, ay, 4, 0, Math.PI * 2); - context.fill(); - } + drawStationNormal(context, scene, line, hover.chainage); } context.restore(); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 9f06d506e..56716eec0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -26,7 +26,8 @@ import { } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input"; -import { drawPickHover } from "./B05_Profile_UI_RouteEdit_Hover"; +import { drawPickHover, drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; +import { drawPickedCurve, traceNodeTriangle } from "./B05_Profile_UI_RouteEdit_Curve_Pick"; import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross"; import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure"; @@ -37,8 +38,8 @@ import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; /** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */ const NODE_R = 6; -/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 조금 굵게 덧그려 밑의 주황이 비치지 않게. */ -const LOCKED_WIDTH = 3; +/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 굵게 · 흰 테(`--map-halo`)를 밑에 깔아 도드라지게(391-3). */ +const LOCKED_WIDTH = 4; /** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). * * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 @@ -52,8 +53,6 @@ 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 { @@ -245,8 +244,9 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou style.getPropertyValue("--map-route") || "#f97316", 2.4, ); - const lockedColor = style.getPropertyValue("--map-route-locked") || "#db2777"; - strokeLockedRoute(context, scene, line, lockedColor); + const lockedColor = style.getPropertyValue("--map-route-locked") || "#ca8a04"; + const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + strokeLockedRoute(context, scene, line, lockedColor, halo); drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다. drawStationMarks(context, scene, line); @@ -270,21 +270,23 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou : scene.locked[index] ? lockedColor : style.getPropertyValue("--map-route") || "#f97316"; - context.beginPath(); - context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); - context.fill(); - context.stroke(); - // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. - if ( - scene.curveOn.length && + // 곡선 없는 교각점(155° 생략 · 손 지움)은 같은 크기 삼각형 · 가운데 비움(391-2). + const noCurve = + scene.curveOn.length > 0 && !scene.curveOn[index] && index > 0 && - index < scene.planned.length - 1 - ) { + index < scene.planned.length - 1; + const radius = index === scene.picked ? NODE_R + 2 : NODE_R; + context.beginPath(); + if (noCurve) traceNodeTriangle(context, x, y, radius); + else context.arc(x, y, radius, 0, Math.PI * 2); + context.fill(); + context.stroke(); + if (noCurve) { context.save(); - context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.fillStyle = halo; context.beginPath(); - context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); + traceNodeTriangle(context, x, y, NODE_R - 2); context.fill(); context.restore(); } @@ -314,6 +316,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou }); }); context.restore(); + drawPickedCurve(context, scene, style); drawPickHover(context, scene, line, style, NODE_R); context.restore(); // 회전 끝 } @@ -392,14 +395,8 @@ function drawMeasureMarks( context.restore(); } -/** - * 고른 측점의 **좌·우 법선**(382-2) — 측점에서 노선에 직각, 길이 = 그 측점 발자국 비탈 끝 좌 · 우 · - * 색 = 깎기(절토) · 쌓기(성토) 토큰(`_Footprint` 와 같은 것) · 기록이 없으면 「횡단 반폭」 양쪽 파랑 · - * 측점 자리에 고리(눈금 강조). 구조물 · 관 고름 = 그 자리 마름모(배수유역도 `markStation` 꼴)만. - * - * 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버 - * `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다. - */ +/** 고른 측점 — 맴돔과 같은 좌우 20 m 법선(391-1 · `drawStationNormal`) · 구조물 · 관 고름 = + * 그 자리 마름모(배수유역도 `markStation` 꼴)만. 절성토 색은 발자국 선이 보여 줌. */ function drawCrossWidth( context: CanvasRenderingContext2D, scene: RouteEditScene, @@ -407,53 +404,23 @@ function drawCrossWidth( ): void { const watch = scene.crossWidth; if (!watch) return; + if (!watch.structure) { + drawStationNormal(context, scene, line, watch.chainageM); + return; + } const frame = stationFrameAt(line, watch.chainageM); if (!frame) return; - const [ox, oy] = frame.origin; - const [lx, ly] = frame.left; const [cx, cy] = scene.toScreen(frame.origin); context.save(); context.setLineDash([]); - if (watch.structure) { - context.translate(cx, cy); - context.rotate(Math.PI / 4); - context.beginPath(); - context.rect(-7, -7, 14, 14); - context.fillStyle = "rgba(255, 196, 0, 0.35)"; - context.fill(); - context.lineWidth = 2.5; - context.strokeStyle = "#ff9800"; - context.stroke(); - context.restore(); - return; - } - const style = getComputedStyle(document.documentElement); - const tone = (cut: boolean | undefined): string => - cut === undefined - ? CROSS_WIDTH_COLOR - : style.getPropertyValue(cut ? "--b05-footprint-cut" : "--b05-footprint-fill").trim() || - (cut ? "#dc2626" : "#2563eb"); - const ends: Array<[Vertex, string]> = [ - [[ox + lx * watch.leftM, oy + ly * watch.leftM], tone(watch.leftCut)], - [[ox - lx * watch.rightM, oy - ly * watch.rightM], tone(watch.rightCut)], - ]; - context.lineWidth = 2.4; - ends.forEach(([end, color]) => { - const [x, y] = scene.toScreen(end); - context.strokeStyle = color; - context.fillStyle = color; - 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.strokeStyle = CROSS_WIDTH_COLOR; - context.lineWidth = 2; + context.translate(cx, cy); + context.rotate(Math.PI / 4); context.beginPath(); - context.arc(cx, cy, 6, 0, Math.PI * 2); + context.rect(-7, -7, 14, 14); + context.fillStyle = "rgba(255, 196, 0, 0.35)"; + context.fill(); + context.lineWidth = 2.5; + context.strokeStyle = "#ff9800"; context.stroke(); context.restore(); } @@ -597,6 +564,7 @@ function strokeLockedRoute( scene: RouteEditScene, line: ReadonlyArray, color: string, + halo: string, ): void { const { planned, locked } = scene; if (!locked.some(Boolean)) return; @@ -616,9 +584,10 @@ function strokeLockedRoute( curveAt.forEach((curve, index) => { if (arcLocked(locked, index)) pieces.push(sliceBetween(line, curve.start, curve.end)); }); - pieces.forEach((piece) => - strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH), - ); + pieces.forEach((piece) => { + strokePolyline(context, scene.toScreen, piece, [], halo, LOCKED_WIDTH + 3); + strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH); + }); } /** 노선에서 두 점(정점 위)에 가장 가까운 정점 사이를 잘라 낸다. */ diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts index 107591c4e..11c0f1fb2 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts @@ -196,6 +196,23 @@ export function curveArcVertices(curve: EditedCurve): Vertex[] { return [curve.start, ...arcPoints(center, curve.start, curve.end, sweep < 0), curve.end]; } +/** 곡선 가운데 MC — 교각점에서 이등분선 따라 외할(R/sin(I/2) − R). 화면 표시 전용 · 짝 아님(391-2). */ +export function curveMidVertex(curve: EditedCurve): Vertex { + const toStart = unit(curve.apex, curve.start); + const toEnd = unit(curve.apex, curve.end); + const bisector: Vertex = [toStart[0] + toEnd[0], toStart[1] + toEnd[1]]; + const bisectorLength = Math.hypot(bisector[0], bisector[1]); + const half = Math.sin((curve.inner_angle_deg * RAD_PER_DEG) / 2); + if (bisectorLength <= 1e-9 || !(half > 1e-9)) { + return [(curve.start[0] + curve.end[0]) / 2, (curve.start[1] + curve.end[1]) / 2]; + } + const external = curve.radius_m / half - curve.radius_m; + return [ + curve.apex[0] + (bisector[0] / bisectorLength) * external, + curve.apex[1] + (bisector[1] / bisectorLength) * external, + ]; +} + /** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */ export interface EditedNode { inner_angle_deg: number | null; diff --git a/resources/tester/helper_391_routeedit_plan_draw.cjs b/resources/tester/helper_391_routeedit_plan_draw.cjs new file mode 100644 index 000000000..91ba4ea0c --- /dev/null +++ b/resources/tester/helper_391_routeedit_plan_draw.cjs @@ -0,0 +1,117 @@ +/* 391 B05 평면 그림 단위검증 헬퍼 — 측점 법선(`_Hover.drawStationNormal`) · 고른 곡선 밝힘 · + * 삼각형(`_Curve_Pick`) · MC 점(`logic/_Curve.curveMidVertex`)을 트랜스파일해 가짜 캔버스로 돌린다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); + +const STUBS = { "./B05_Profile_UI_RouteEdit_Input": { pickHover: () => null } }; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +/** 그린 것만 적는 가짜 캔버스 — 선 = moveTo/lineTo 점 · 점 = arc 가운데 · 획 색. */ +function fakeContext() { + const log = { lines: [], dots: [], strokes: [] }; + let path = []; + const ctx = { + save() {}, + restore() {}, + setLineDash() {}, + beginPath: () => (path = []), + moveTo: (x, y) => path.push([x, y]), + lineTo: (x, y) => path.push([x, y]), + closePath() {}, + arc: (x, y, r) => log.dots.push([x, y, r]), + stroke: () => { + log.strokes.push(ctx.strokeStyle); + if (path.length) log.lines.push({ color: ctx.strokeStyle, width: ctx.lineWidth, path }); + }, + fill() {}, + }; + return { ctx, log, path: () => path }; +} + +const round = (v) => Math.round(v * 1000) / 1000; +const hover = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Hover.ts")); +const pick = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Curve_Pick.ts")); +const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts")); +const style = { getPropertyValue: () => "" }; +const out = {}; + +// 측점 법선 — 동서 노선 50 m 자리 · 화면 = 2 px/m +const scene = { toScreen: ([x, y]) => [x * 2, y * 2] }; +const normal = fakeContext(); +hover.drawStationNormal( + normal.ctx, + scene, + [ + [0, 0], + [100, 0], + ], + 50, +); +out.normal = normal.log.lines.map((l) => ({ + color: l.color, + path: l.path.map((p) => p.map(round)), +})); + +// 직각 곡선 R 10 — 교각점 (0,0) · BC (-10,0) · EC (0,10) +const right = { + apex: [0, 0], + radius_m: 10, + tangent_m: 10, + inner_angle_deg: 90, + start: [-10, 0], + end: [0, 10], + node_first: 1, + node_last: 1, + violations: [], +}; +out.mid = curve.curveMidVertex(right).map(round); + +const unit = { toScreen: ([x, y]) => [x, y] }; +const picked = fakeContext(); +pick.drawPickedCurve( + picked.ctx, + { ...unit, picked: 1, curveInfo: [right], curveOn: [true, true, true] }, + style, +); +out.picked = { + dots: picked.log.dots.map(([x, y]) => [round(x), round(y)]), + tangent: picked.log.lines[0].path, + arcWidths: picked.log.lines.slice(1).map((l) => l.width), +}; +const off = fakeContext(); +pick.drawPickedCurve( + off.ctx, + { ...unit, picked: 1, curveInfo: [right], curveOn: [true, false, true] }, + style, +); +const none = fakeContext(); +pick.drawPickedCurve( + none.ctx, + { ...unit, picked: -1, curveInfo: [right], curveOn: [true, true, true] }, + style, +); +out.offDrawn = off.log.strokes.length + none.log.strokes.length; + +const tri = fakeContext(); +pick.traceNodeTriangle(tri.ctx, 100, 100, 6); +out.triangle = tri.path().map((p) => p.map(round)); +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_381_3_routeedit_node_pick.py b/resources/tester/test_381_3_routeedit_node_pick.py index 2cf8264f1..977ab495c 100644 --- a/resources/tester/test_381_3_routeedit_node_pick.py +++ b/resources/tester/test_381_3_routeedit_node_pick.py @@ -47,4 +47,9 @@ def test_집기_맴돔_한_함수(): def test_맴돔_그림(): hover = _read("B05_Profile_UI_RouteEdit_Hover.ts") assert "pickHover(context.canvas)" in hover - assert '"node" in hover' in hover and "stationFrameAt(line, hover.chainage)" in hover + assert ( + '"node" in hover' in hover + and "drawStationNormal(context, scene, line, hover.chainage)" in hover + ) + # 391-1 맴돔 = 고름 한 함수 · 좌우 20 m 고정 + assert "const STATION_NORMAL_HALF_M = 20;" in hover diff --git a/resources/tester/test_391_routeedit_plan_draw.py b/resources/tester/test_391_routeedit_plan_draw.py new file mode 100644 index 000000000..fd66264de --- /dev/null +++ b/resources/tester/test_391_routeedit_plan_draw.py @@ -0,0 +1,93 @@ +"""391 B05 평면 그림(요구 242-5 · 6 · 12 · 13 · 15) — 20 m 법선 · 곡선 3점 · 삼각형 · 잠금 색. + +Node 헬퍼(`helper_391_routeedit_plan_draw.cjs`)가 TS 를 트랜스파일해 가짜 캔버스로 돌림. +./venv/Scripts/python.exe -m pytest resources/tester/test_391_routeedit_plan_draw.py -q +""" + +import json +import math +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +B05 = ROOT / "B05_Profile" + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", str(HERE / "helper_391_routeedit_plan_draw.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def _read(name: str) -> str: + return (B05 / name).read_text(encoding="utf-8") + + +def test_법선은_좌우_20m_하늘색(results): + line = results["normal"][0] + assert line["color"] == "#0ea5e9" + # 2 px/m · 동서 노선 50 m 자리 → 좌(북) 20 m · 우(남) 20 m + assert line["path"] == [[100, 40], [100, -40]] + + +def test_맴돔과_고름이_한_함수(): + hover = _read("B05_Profile_UI_RouteEdit_Hover.ts") + assert "drawStationNormal(context, scene, line, hover.chainage);" in hover + assert "* 12" not in hover # 짧은 12 px 선 지움 + render = _read("B05_Profile_UI_RouteEdit_Render.ts") + assert "drawStationNormal(context, scene, line, watch.chainageM);" in render + assert "leftCut" not in render and "--b05-footprint-cut" not in render + assert 'context.strokeStyle = "#ff9800";' in render # 구조물 마름모 그대로 + + +def test_MC_는_호_가운데(results): + r = 10 * (1 - 1 / math.sqrt(2)) + assert results["mid"] == [round(-r, 3), round(r, 3)] + + +def test_고른_곡선_세_점_접선_굵은_호(results): + picked = results["picked"] + assert picked["dots"][0] == [-10, 0] and picked["dots"][2] == [0, 10] + assert picked["dots"][1] == results["mid"] + assert picked["tangent"] == [[-10, 0], [0, 0], [0, 10]] + assert picked["arcWidths"] == [8, 5] # 흰 테 · 하늘색 + + +def test_곡선_없음_고름_없음은_안_그림(results): + assert results["offDrawn"] == 0 + + +def test_곡선_없는_교각점은_삼각형(results): + tri = results["triangle"] + assert len(tri) == 3 and tri[0] == [100, round(100 - 6 * 1.4, 3)] + render = _read("B05_Profile_UI_RouteEdit_Render.ts") + assert "if (noCurve) traceNodeTriangle(context, x, y, radius);" in render + assert "drawPickedCurve(context, scene, style);" in render + assert render.index("drawPickedCurve(") < render.index("drawPickHover(context") + + +def test_잠금_황금_굵기4_흰테(): + css = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8") + assert css.count("--map-route-locked: #ca8a04;") == 1 + assert css.count("--map-route-locked: #facc15;") == 2 # 어두움 · 시스템 어두움 + render = _read("B05_Profile_UI_RouteEdit_Render.ts") + assert "const LOCKED_WIDTH = 4;" in render + assert "piece, [], halo, LOCKED_WIDTH + 3);" in render + + +def test_파일_700줄_안(): + for name in ( + "B05_Profile_UI_RouteEdit_Render.ts", + "B05_Profile_UI_RouteEdit_Hover.ts", + "B05_Profile_UI_RouteEdit_Curve_Pick.ts", + ): + assert len(_read(name).splitlines()) <= 700, name diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index fc1bd6fe8..51797934f 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -232,7 +232,7 @@ * 배경이 위성사진이라 라이트/다크 공통으로 같은 색을 쓴다. * ------------------------------------------------------------------------ */ --map-route: #f97316; /* 계획선 (B04 지도·B05 배수유역도 공통) */ - --map-route-locked: #db2777; /* 계획노선 편집 — 고정한 선 · 호 · 점 (주황 · 초록 · 연보라 · 하늘색과 구별) */ + --map-route-locked: #ca8a04; /* 계획노선 편집 — 고정한 선 · 호 · 점 황금 (평면의 주황 · 절토 붉음 · 성토 청록 · 하늘색과 구별) */ --map-cadastral: #f97316; --map-sigungu: #7c3aed; --map-eupmyeondong: #22c55e; @@ -333,7 +333,7 @@ --color-warning: #e0b64a; --color-danger: #e8695a; --panel-handle-border: #e0b64a; - --map-route-locked: #f472b6; + --map-route-locked: #facc15; --shadow-sm: rgba(0, 0, 0, 0.4) 0px 2px 4px 0px, rgba(0, 0, 0, 0.2) 0px 0px 0px 1px; --shadow-lg: rgba(0, 0, 0, 0.5) 0px 32px 24px -12px, rgba(0, 0, 0, 0.3) 0px 3px 3px 0px; @@ -374,7 +374,7 @@ --color-success: #3fbb6f; --color-danger: #e8695a; --panel-handle-border: #e0b64a; - --map-route-locked: #f472b6; + --map-route-locked: #facc15; } }