diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts
index 00aa2b95..7921d46e 100644
--- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts
+++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts
@@ -438,6 +438,33 @@ function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): b
);
}
+/** 레이어가 차지하는 **화면 사각형**(px). 피처가 없으면 null.
+ *
+ * LAS 등고선처럼 도엽보다 좁은 자료 위에 다른 레이어를 겹칠 때, 그 자료가 있는 데까지만
+ * 그리려고 쓴다(계획서 0-9 ⑮ — 세류선이 등고선 밖까지 뻗던 자리). */
+export function layerScreenBounds(
+ layer: PreparedLayer,
+ view: ViewState,
+): { x: number; y: number; width: number; height: number } | null {
+ if (layer.features.length === 0) return null;
+ const affine = affineOf(view);
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ for (const feature of layer.features) {
+ minX = Math.min(minX, feature.minX);
+ minY = Math.min(minY, feature.minY);
+ maxX = Math.max(maxX, feature.maxX);
+ maxY = Math.max(maxY, feature.maxY);
+ }
+ const x0 = minX * affine.ax + affine.bx;
+ const x1 = maxX * affine.ax + affine.bx;
+ const y0 = minY * affine.ay + affine.by;
+ const y1 = maxY * affine.ay + affine.by;
+ return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
+}
+
/** 등고선을 몇 m 마다 낼지 고를 때 훑는 배수. 성긴 쪽으로 한 칸씩 물러난다. */
const LEVEL_STEP_MULTIPLES = [1, 2, 5, 10, 20, 50, 100];
/** 한 화면에 둘 등고선 가닥 수의 어림 상한 — 이보다 많으면 한 칸 성글게 간다. */
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts
index 99c76a89..dd517a27 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts
@@ -45,6 +45,7 @@ import {
stationAtScreen,
} from "./B05_Profile_UI_RouteEdit_Input";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
+import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
@@ -98,30 +99,40 @@ export async function openRouteEditModal(
계획노선 편집
-
- 노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 ·
- 노드 오른쪽 클릭 = 삭제 · 측점 눈금 클릭 = 횡단 미리보기 ·
- Shift+클릭 = 두 점 사이 거리·기울기 ·
- 가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
+
+
+
+
+
+
+
-
-
- 노선을 읽는 중…
-
- 예상노선(원본)
- 계획노선
-
-
-
-
-
-
-
+
+
+
+ 노드 끌기 = 옮기기노드 클릭 = R 라벨
+ 선 두 번 클릭 = 노드 추가노드 오른쪽 클릭 = 삭제
+ 측점 눈금 클릭 = 횡단 미리보기Shift+클릭 = 거리·기울기
+ 가운데(휠) 버튼 끌기 = 지도 이동휠 = 확대
+
+
+
+
+
+
+ 노선을 읽는 중…
+
+ 예상노선(원본)
+ 계획노선
+
+
`;
@@ -200,6 +211,15 @@ export async function openRouteEditModal(
mapRect: computeMapRect(null, 0, 0),
};
let closed = false;
+ /** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */
+ const rotation = createMapRotation({
+ overlay,
+ size: () => view,
+ onChange: () => {
+ syncCurveBar(); // 떠 있는 패널도 돌아간 노드 옆으로 따라가야 한다.
+ draw();
+ },
+ });
const close = (): void => {
closed = true;
@@ -271,6 +291,7 @@ export async function openRouteEditModal(
otherSheets,
pickedContour,
contourStepM: contourStepM(),
+ rotationRad: rotation.radians(),
measure: measure.points(),
expected,
plannedLine,
@@ -324,7 +345,7 @@ export async function openRouteEditModal(
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
applyArcLocks(planned, curveLock, curveArc, curveRadius);
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
- applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
+ applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM);
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
plannedLine = built.vertices;
curveInfo = built.curves;
@@ -372,7 +393,7 @@ export async function openRouteEditModal(
limitRadiusM,
limitArcM,
}),
- toScreen: (vertex) => toScreen(vertex),
+ toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
applyEdit: (message) => applyEdit(message),
});
const curveLabelBox = curveBar.label;
@@ -419,8 +440,7 @@ export async function openRouteEditModal(
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const rect = canvas.getBoundingClientRect();
- const px = event.clientX - rect.left;
- const py = event.clientY - rect.top;
+ const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (event.shiftKey) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void measure.pick(px, py);
@@ -472,8 +492,7 @@ export async function openRouteEditModal(
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
- const px = event.clientX - rect.left;
- const py = event.clientY - rect.top;
+ const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
const node = dragHandle.node;
@@ -497,12 +516,12 @@ export async function openRouteEditModal(
if (dragNode >= 0) {
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
- const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM);
+ const before = curveShortfalls(nodeInfo, limitRadiusM);
const previous = planned[dragNode];
dragMoved = true;
planned[dragNode] = toMetric(px, py);
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
- if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) {
+ if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM))) {
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
planned[dragNode] = previous;
markEdited();
@@ -538,8 +557,7 @@ export async function openRouteEditModal(
canvas.addEventListener("dblclick", (event) => {
const rect = canvas.getBoundingClientRect();
- const px = event.clientX - rect.left;
- const py = event.clientY - rect.top;
+ const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const segment = segmentAt(px, py);
if (segment < 0) return;
planned.splice(segment + 1, 0, toMetric(px, py));
@@ -555,7 +573,7 @@ export async function openRouteEditModal(
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
- const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
+ const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top));
if (index < 0) return;
if (planned.length <= 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
@@ -578,6 +596,8 @@ export async function openRouteEditModal(
view = next;
},
getMeta: () => meta,
+ // 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다.
+ unrotateDelta: rotation.unrotateDelta,
draw,
});
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts
index d5050907..d60e6372 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts
@@ -28,6 +28,12 @@ import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
const SHEET_ELEVATION_KEYS = ["등고수치"];
/** 표고를 못 읽었을 때 라벨 솎기에 쓸 간격(m). */
const FALLBACK_INTERVAL_M = 5;
+/** LAS 등고선을 **몇 m 단위로** 낼지 — 도엽 등고선과 같은 눈금(2026-09-12 사용자 지시 ⑭).
+ *
+ * LAS 자료는 1m 간격으로 뽑혀 있어 그대로 쓰면 확대할 때 1m·2m 짜리까지 나온다. 서버에 5m
+ * 로 다시 뽑아 달라고 하면 첫 한 번이 오래 걸리므로 **받아 둔 1m 자료에서 5의 배수만 고른다**
+ * — 그림도 라벨도 도엽 쪽과 같은 눈금이 된다. */
+const LAS_CONTOUR_UNIT_M = 5;
export interface RouteEditContours {
layer: PreparedLayer;
@@ -54,6 +60,8 @@ export async function loadRouteEditContours(
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
): Promise {
if (options.surfaceModelId !== null) {
+ // 받아 오는 간격은 프로젝트 설정 그대로(보관함에 이미 있는 파일을 쓰려는 것) —
+ // **보이는 눈금**은 아래에서 5m 로 맞춘다.
const interval = options.intervalM > 0 ? options.intervalM : 1;
try {
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
@@ -63,13 +71,19 @@ export async function loadRouteEditContours(
`/contour?interval=${interval}&smooth=${options.smooth}`,
);
const lines = (data.contours ?? [])
+ // 5m 단위만 남긴다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다.
+ .filter((contour) => Math.abs(contour.level % LAS_CONTOUR_UNIT_M) < 1e-6)
.map((contour) => ({
points: contour.coordinates.map(([x, y]) => [x, y] as const),
label: contour.level,
}))
.filter((line) => line.points.length >= 2);
if (lines.length > 0) {
- return { layer: prepareMetricPolylines(lines, meta), intervalM: interval, source: "las" };
+ return {
+ layer: prepareMetricPolylines(lines, meta),
+ intervalM: LAS_CONTOUR_UNIT_M,
+ source: "las",
+ };
}
} catch {
/* 내려앉는다 — 아래 도엽 갈래로 이어 간다. */
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts
index 97f404c0..7abac1d8 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts
@@ -37,6 +37,7 @@ export interface CurveBarState {
export interface CurveBarParams {
canvas: HTMLCanvasElement;
state: () => CurveBarState;
+ /** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
toScreen: (vertex: Vertex) => [number, number];
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
applyEdit: (message: string) => void;
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts
index 55dcd2db..1ff96453 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts
@@ -49,35 +49,36 @@ export function applyArcLocks(
}
/**
- * **하한을 지키도록 지정 반지름을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정).
+ * **반지름 하한을 지키도록 지정값을 끌어올린다**(계획서 0-9 ④, 2026-09-12 사용자 확정).
*
- * L = R·Δ 이므로 「곡선 길이 하한」은 그 자리에서 「반지름 하한 L/Δ」과 같은 말이다. 두 하한
- * 중 큰 쪽으로 올린다. **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을
- * 쓰고 있고, 여기서 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다.
+ * **비워 둔(자동) 자리는 건드리지 않는다** — 자동은 이미 기본 반지름을 쓰고 있고, 여기서
+ * 값을 적어 넣으면 아무것도 안 고쳤는데 「R 지정」이 늘어난다.
+ *
+ * ⚠ **곡선 길이(L) 하한은 여기서 안 건다.** L = R·Δ 라, 내각이 179° 처럼 거의 곧은 자리는
+ * L 5m 를 채우려면 R 이 286m 로 부풀어 **아무것도 안 고쳤는데 노선이 바뀐다**(2026-09-12
+ * 실화면: 길이 1017.5m → 1017.2m). 별표2 도 내각 155° 이상은 곡선을 안 둘 수 있다고 한다.
+ * L 하한은 **사용자가 칸에 적을 때만** 막고(`_Label`), 패널에 「L ≥ 5m」으로 보이기만 한다.
*/
export function applyCurveLimits(
planned: Vertex[],
curveOn: ReadonlyArray,
curveRadius: Array,
limitRadiusM: number,
- limitArcM: number,
): void {
- if (limitRadiusM <= 0 && limitArcM <= 0) return;
+ if (limitRadiusM <= 0) return;
for (let seat = 1; seat < planned.length - 1; seat += 1) {
if (curveOn[seat] === false) continue;
const current = curveRadius[seat];
if (current === null || current === undefined) continue;
- const deflection = deflectionRad(
- innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]),
- );
- const byArc = limitArcM > 0 && deflection > 1e-9 ? limitArcM / deflection : 0;
- const floor = Math.max(limitRadiusM, byArc);
- if (floor > 0 && current < floor) curveRadius[seat] = floor;
+ if (current < limitRadiusM) curveRadius[seat] = limitRadiusM;
}
}
/**
- * 노드마다 하한을 **얼마나 밑돌고 있나**(m). 다 지키고 있으면 0.
+ * 노드마다 **반지름 하한**을 얼마나 밑돌고 있나(m). 다 지키고 있으면 0.
+ *
+ * 곡선 길이(L) 하한은 여기서도 안 본다 — 까닭은 `applyCurveLimits` 설명에 있다. 거의 곧은
+ * 자리에서 L 을 채우라고 막으면 **노드를 한 뼘도 못 옮긴다**.
*
* 노드를 옮기면 접선 자리가 모자라 그리기 단계에서 R 이 눌릴 수 있다. 그 눌림까지 막으려면
* 옮기기 자체를 되돌려야 하므로, **옮기기 전보다 나빠진 자리가 있는지**만 견준다 — 이미
@@ -87,19 +88,10 @@ export function applyCurveLimits(
* 무너져도 최댓값이 안 움직여** 그냥 통과한다(실화면에서 「기준 미달 1곳 → 2곳」이 그대로
* 지나갔다). 자리마다 따로 견준다.
*/
-export function curveShortfalls(
- nodes: ReadonlyArray,
- limitRadiusM: number,
- limitArcM: number,
-): number[] {
+export function curveShortfalls(nodes: ReadonlyArray, limitRadiusM: number): number[] {
return nodes.map((node) => {
- if (node.radius_m === null || (limitRadiusM <= 0 && limitArcM <= 0)) return 0;
- let short = 0;
- if (limitRadiusM > 0) short = Math.max(short, limitRadiusM - node.radius_m);
- if (limitArcM > 0) {
- short = Math.max(short, limitArcM - node.radius_m * deflectionRad(node.inner_angle_deg));
- }
- return Math.max(0, short);
+ if (node.radius_m === null || limitRadiusM <= 0) return 0;
+ return Math.max(0, limitRadiusM - node.radius_m);
});
}
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts
index 5c4256ff..e4ab7f24 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts
@@ -29,12 +29,16 @@ export interface RouteEditNavigationParams {
getView: () => ViewState;
setView: (next: ViewState) => void;
getMeta: () => VWorldMeta | null;
+ /** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다.
+ * 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */
+ unrotateDelta?: (dx: number, dy: number) => [number, number];
draw: () => void;
}
/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
const { canvas, getView, setView, getMeta, draw } = params;
+ const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]);
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
canvas.addEventListener(
@@ -53,8 +57,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
const ratio = scale / view.scale;
const rect = canvas.getBoundingClientRect();
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
- const cursorX = event.clientX - rect.left - rect.width / 2;
- const cursorY = event.clientY - rect.top - rect.height / 2;
+ const [cursorX, cursorY] = unrotateDelta(
+ event.clientX - rect.left - rect.width / 2,
+ event.clientY - rect.top - rect.height / 2,
+ );
setView({
...view,
scale,
@@ -84,11 +90,8 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
canvas.addEventListener("pointermove", (event) => {
if (!dragStart) return;
- setView({
- ...getView(),
- offsetX: dragStart.offsetX + event.clientX - dragStart.x,
- offsetY: dragStart.offsetY + event.clientY - dragStart.y,
- });
+ const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y);
+ setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy });
draw();
});
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
index b3175d89..7e19c6e5 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
@@ -14,6 +14,7 @@ import {
drawPreparedFeature,
drawPreparedLabels,
drawPreparedLayer,
+ layerScreenBounds,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
@@ -81,6 +82,8 @@ export interface RouteEditScene {
stationIntervalM: number;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리(계획서 0-9 ⑤). */
measure: ReadonlyArray;
+ /** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */
+ rotationRad: number;
}
export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void {
@@ -91,6 +94,15 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
+ // 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯).
+ // 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다.
+ context.save();
+ if (scene.rotationRad) {
+ context.translate(view.width / 2, view.height / 2);
+ context.rotate(scene.rotationRad);
+ context.translate(-view.width / 2, -view.height / 2);
+ }
+
context.save();
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
@@ -102,7 +114,18 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
}
context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb";
context.lineWidth = 1.2;
+ // 바탕이 LAS 면 세류선도 **등고선이 있는 데까지만** 그린다(2026-09-12 사용자 지시 ⑮) —
+ // 도엽 하천중심선은 도엽 전체를 덮어 LAS 자료 밖까지 길게 뻗는다.
+ const lasBox =
+ scene.contours?.source === "las" ? layerScreenBounds(scene.contours.layer, view) : null;
+ context.save();
+ if (lasBox) {
+ context.beginPath();
+ context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
+ context.clip();
+ }
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
+ context.restore();
if (scene.contours) {
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
const everyM = scene.contourStepM;
@@ -206,6 +229,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
drawStationMarks(context, scene, line);
drawMeasureMarks(context, scene, line);
+ context.restore(); // 회전 끝
}
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts
new file mode 100644
index 00000000..63bb9f36
--- /dev/null
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts
@@ -0,0 +1,71 @@
+/* =============================================================================
+ * B05_Profile_UI_RouteEdit_Rotate.ts
+ * 지도 회전 — 반시계·시계 단추 하나씩, 누를 때마다 **그림 전체**가 한 칸 돈다
+ * (계획서 0-9 ⑯, 2026-09-12 사용자 지시).
+ *
+ * 축은 **캔버스 한가운데**다. 그리기는 캔버스 변환으로 한 번에 돌리고(글자도 함께 돈다 —
+ * CAD 도면과 같은 방식), 집기는 들어온 화면 자리를 **거꾸로 돌려** 안 돌린 좌표로 바꿔 쓴다.
+ * 그래야 눈에 보이는 자리와 잡히는 자리가 같다.
+ * ========================================================================== */
+
+/** 단추 한 번에 도는 각(도) — 15°씩 스물넷이면 한 바퀴. 90°씩이면 맞출 자리가 넷뿐이고,
+ * 5°씩이면 한 바퀴에 일흔두 번이라 성가시다. */
+const ROTATE_STEP_DEG = 15;
+
+export interface MapRotationParams {
+ /** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */
+ overlay: HTMLElement;
+ /** 지금 캔버스 크기 — 회전축(한가운데)을 잡는 데 쓴다. */
+ size: () => { width: number; height: number };
+ /** 각이 바뀌었다 — 호출부가 다시 그린다. */
+ onChange: () => void;
+}
+
+export interface MapRotation {
+ /** 지금 돌린 각(라디안). 그리기가 캔버스 변환에 그대로 쓴다. */
+ radians: () => number;
+ /** 화면에 보이는 자리 → 그리기 좌표(돌리기 전). */
+ unrotate: (px: number, py: number) => [number, number];
+ /** 그리기 좌표 → 화면에 보이는 자리. 떠 있는 패널을 노드 옆에 붙일 때 쓴다. */
+ rerotate: (px: number, py: number) => [number, number];
+ /** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */
+ unrotateDelta: (dx: number, dy: number) => [number, number];
+}
+
+export function createMapRotation(params: MapRotationParams): MapRotation {
+ let radians = 0;
+
+ const spin = (px: number, py: number, angle: number): [number, number] => {
+ if (!angle) return [px, py];
+ const { width, height } = params.size();
+ const cx = width / 2;
+ const cy = height / 2;
+ const cos = Math.cos(angle);
+ const sin = Math.sin(angle);
+ const dx = px - cx;
+ const dy = py - cy;
+ return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
+ };
+
+ for (const [act, sign] of [
+ ["rotate-ccw", -1],
+ ["rotate-cw", 1],
+ ] as const) {
+ params.overlay.querySelector(`[data-act="${act}"]`)?.addEventListener("click", () => {
+ radians += (sign * ROTATE_STEP_DEG * Math.PI) / 180;
+ params.onChange();
+ });
+ }
+
+ return {
+ radians: () => radians,
+ unrotate: (px, py) => spin(px, py, -radians),
+ rerotate: (px, py) => spin(px, py, radians),
+ unrotateDelta: (dx, dy) => {
+ if (!radians) return [dx, dy];
+ const cos = Math.cos(-radians);
+ const sin = Math.sin(-radians);
+ return [dx * cos - dy * sin, dx * sin + dy * cos];
+ },
+ };
+}
diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
index 361a73f2..ddf5ce79 100644
--- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
+++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css
@@ -33,10 +33,15 @@
border-bottom: 1px solid var(--color-border);
}
-.b05-routeedit__hint {
+/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪).
+ 단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */
+.b05-routeedit__actions {
+ display: flex;
flex: 1 1 auto;
- color: var(--color-text-secondary);
- font-size: var(--text-caption);
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: var(--spacing-8);
}
.b05-routeedit__close {
@@ -62,27 +67,63 @@
touch-action: none;
}
-.b05-routeedit__foot {
- display: flex;
- flex: none;
- align-items: center;
- gap: var(--spacing-8);
- padding: var(--spacing-12) var(--spacing-16);
- border-top: 1px solid var(--color-border);
-}
-
-.b05-routeedit__status {
- flex: 1 1 auto;
+/* 지도 위에 얹는 판들 — 아래 정보행을 없애고 여기로 옮겼다(2026-09-12 사용자 지시 ⑩·⑫·⑬).
+ 글판은 **클릭을 통과시킨다** — 지도 조작을 가리면 안 된다. */
+.b05-routeedit__hint,
+.b05-routeedit__info {
+ position: absolute;
+ z-index: 1;
+ padding: var(--spacing-8) var(--spacing-12);
+ border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
+ border-radius: var(--radius-8, 6px);
+ background: color-mix(in srgb, var(--color-surface-raised) 78%, transparent);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
color: var(--color-text-secondary);
font-size: var(--text-caption);
+ pointer-events: none;
+}
+
+/* 조작 설명 — **2열**로 묶는다(사용자 지시 ⑩). 한 줄로 늘어놓으면 창이 좁을 때 접힌다. */
+.b05-routeedit__hint {
+ top: var(--spacing-12);
+ left: var(--spacing-12);
+ display: grid;
+ grid-template-columns: auto auto;
+ gap: 2px var(--spacing-16);
+ max-width: 52%;
+}
+
+/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫). */
+.b05-routeedit__info {
+ bottom: var(--spacing-12);
+ left: var(--spacing-12);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-width: 62%;
+}
+
+/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */
+.b05-routeedit__spin {
+ position: absolute;
+ top: var(--spacing-12);
+ right: var(--spacing-12);
+ z-index: 1;
+ display: flex;
+ gap: var(--spacing-8);
+}
+
+.b05-routeedit__spin .ui-btn {
+ padding: var(--spacing-8) var(--spacing-12);
+ font-size: var(--text-body);
+ line-height: 1;
}
.b05-routeedit__legend {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
- color: var(--color-text-secondary);
- font-size: var(--text-caption);
}
.b05-routeedit__legend i {
@@ -101,22 +142,6 @@
border-top: 2px solid var(--map-route, #f97316);
}
-.b05-routeedit__btn {
- flex: none;
- padding: var(--spacing-8) var(--spacing-16);
- border: 1px solid var(--color-border);
- border-radius: var(--radius-8, 6px);
- background: var(--color-surface);
- color: var(--color-text-body);
- cursor: pointer;
-}
-
-.b05-routeedit__btn.is-primary {
- border-color: transparent;
- background: var(--color-primary, #7c3aed);
- color: #fff;
-}
-
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
.b05-routeedit__busy {
position: absolute;
diff --git a/B06_Section/B06_Section_UI_Cross_CutSlope.ts b/B06_Section/B06_Section_UI_Cross_CutSlope.ts
index 23d1f842..bc0e2232 100644
--- a/B06_Section/B06_Section_UI_Cross_CutSlope.ts
+++ b/B06_Section/B06_Section_UI_Cross_CutSlope.ts
@@ -6,8 +6,9 @@
* 좌측 패널의 표준 횡단면 설정을 이용」. 그래서 **전체 기본값은 좌측 패널**, **이 칸은 그 측점
* 하나**만 바꾼다. 표준을 바꿔도 여기서 만진 측점은 그대로다(사용자 값이라 재계산이 살려 둔다).
*
- * 화면에는 **각도(°)**로 보이고 속으로는 경사비(1:n 의 n)로 다닌다 — 소단 폼의
- * 「안쪽 기울기 (°)」와 같은 말법이다. 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
+ * 화면도 속도 **경사비(1:n 의 n)** 로 쓴다(2026-09-12 사용자 지시). 종전에는 화면만 각도(°)로
+ * 바꿔 보여 줬는데, 도면·좌측 [표준 횡단면 설정]·품셈이 모두 1:n 이라 이 칸만 머리로 환산해야
+ * 했다. 참고: 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
*
* ⚠ 경사는 **나르는 값이 아니라 기하 입력**이다. 계산이 끝난 결과에 값만 베껴 붙이면 설계선은
* 옛 경사로 그려지고 숫자만 새것이 된다 — 값은 반드시 **계산 전에** 넘어가야 한다
@@ -37,13 +38,18 @@ export function degreesToRatio(degrees: number): number {
return 1 / Math.tan((clamped * Math.PI) / 180);
}
-/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
-function sameDegrees(a: number, b: number): boolean {
- return Math.abs(a - b) < 0.05;
+/** 경사비를 화면 자릿수(0.01)로 맞춘다 — 사면이 서지 않는 각도(1~89° 밖)는 잘라 받는다. */
+export function clampRatio(ratio: number): number {
+ return Math.round(degreesToRatio(ratioToDegrees(ratio)) * 100) / 100;
+}
+
+/** 0.01 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
+function sameRatio(a: number, b: number): boolean {
+ return Math.abs(a - b) < 0.005;
}
/**
- * 카드 하단에 서는 「암 절토 68.2° ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다.
+ * 카드 하단에 서는 「암 절토 1:0.40 ↺」 칸. 암 경계선 제어와 같은 자리·같은 모양이다.
*
* 되돌리기(↺)는 **표준 횡단면 설정값**으로 돌려놓는다 — 좌측 패널을 그 뒤에 바꾸면 이 측점도
* 따라간다(사용자가 만진 흔적이 지워지므로).
@@ -58,30 +64,34 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
const group = document.createElement("div");
group.className = "b06-design__seg-buttons";
- const ratio = control.ratioFor(section);
- const standard = control.standardRatioFor(section);
- const degrees = ratioToDegrees(ratio);
+ const ratio = clampRatio(control.ratioFor(section));
+ const standard = clampRatio(control.standardRatioFor(section));
+
+ // 「1:」은 고정 머리말 — 사용자가 넣는 것은 뒤의 n 하나다.
+ const prefix = document.createElement("span");
+ prefix.className = "b06-design__cutslope-unit";
+ prefix.textContent = "1:";
const input = document.createElement("input");
input.type = "number";
input.className = "b06-design__cutslope-input";
- input.step = "0.5";
- input.min = "1";
- input.max = "89";
- input.value = degrees.toFixed(1);
+ input.step = "0.05";
+ input.min = "0.02";
+ input.max = "57.29";
+ input.value = ratio.toFixed(2);
input.title =
- `이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` +
- `지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
+ `이 측점의 암 절토 경사 — 경사비(1:n 의 n)로 넣는다. n 이 작을수록 급하다.\n` +
+ `지금 1:${ratio.toFixed(2)} (${ratioToDegrees(ratio).toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
`전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`;
const commit = (): void => {
const entered = Number(input.value);
- if (!Number.isFinite(entered)) {
- input.value = degrees.toFixed(1);
+ if (!Number.isFinite(entered) || entered <= 0) {
+ input.value = ratio.toFixed(2);
return;
}
// 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다.
- const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered);
- control.set(section.chainage_m, next);
+ const next = clampRatio(entered);
+ control.set(section.chainage_m, sameRatio(next, standard) ? null : next);
};
input.addEventListener("change", commit);
input.addEventListener("keydown", (event) => {
@@ -93,22 +103,18 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
// 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도.
input.addEventListener("pointerdown", (event) => event.stopPropagation());
- const unit = document.createElement("span");
- unit.className = "b06-design__cutslope-unit";
- unit.textContent = "°";
-
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b06-design__rockb-btn is-reset";
reset.textContent = "↺";
reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`;
- reset.disabled = sameDegrees(degrees, ratioToDegrees(standard));
+ reset.disabled = sameRatio(ratio, standard);
reset.addEventListener("click", (event) => {
event.stopPropagation();
control.set(section.chainage_m, null);
});
- group.append(input, unit, reset);
+ group.append(prefix, input, reset);
wrap.append(group);
return wrap;
}
diff --git a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts
index 717aa941..9905c0c2 100644
--- a/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts
+++ b/B06_Section/B06_Section_UI_Cross_Wall_Hatch.ts
@@ -172,15 +172,9 @@ export function appendWallHatch(
((offset - wall.bottomBack.offset) / bottomSpan)
: wall.bottomBack.elevation;
- // 평행사변형 띠와 사다리꼴 사이 대각 이음선(사용자 스케치의 가운데 선) —
- // 상단 변 중간점에서 전면과 나란히 바닥으로 내려온다. 형태와 무관하게 늘 긋는다.
- const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
- layer.append(
- line(
- [x(wall.topJoint.offset), toDisplayY(wall.topJoint.elevation)],
- [x(jointBaseOffset), toDisplayY(bottomAtOffset(jointBaseOffset))],
- ),
- );
+ // 벽을 가로지르던 대각 이음선은 **긋지 않는다**(2026-09-12 사용자: 필요 없음).
+ // 종전에는 상단 변 중간점에서 바닥까지 형태와 무관하게 늘 그어, 돌쌓기·콘크리트 해칭
+ // 위로 선이 하나 더 지나가 도면이 지저분했다.
clipSeq += 1;
const clipId = `b06-revet-clip-${keyId}-${clipSeq}`;
diff --git a/B06_Section/B06_Section_UI_Style_Cross_Controls.css b/B06_Section/B06_Section_UI_Style_Cross_Controls.css
index e27a0b25..639ca16a 100644
--- a/B06_Section/B06_Section_UI_Style_Cross_Controls.css
+++ b/B06_Section/B06_Section_UI_Style_Cross_Controls.css
@@ -4,11 +4,14 @@
* 넘칠 만큼 길어지면 창 안에서 스크롤한다. z-index는 카드 안 오버레이 중 최상단. */
.b06-structure-panel {
position: absolute;
- top: var(--spacing-8);
+ /* 좌측 상단에는 절·성토 면적표가 이미 서 있다 — 조정창을 같은 자리에 두면 표를 덮어
+ 수치가 안 보인다(2026-09-12 사용자). 표 높이(머리글+절토+성토 세 줄)만큼 내려 시작한다.
+ 아래를 기준으로 붙이지 않는 까닭은 종전 그대로다 — 낮은 횡단도에서 창이 잘린다. */
+ top: calc(var(--spacing-8) + var(--b06-areas-height, 3.2rem));
left: var(--spacing-8);
z-index: 20;
display: flex;
- max-height: calc(100% - 2 * var(--spacing-8));
+ max-height: calc(100% - 2 * var(--spacing-8) - var(--b06-areas-height, 3.2rem));
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
diff --git a/config/config_system_design.py b/config/config_system_design.py
index 45a17e99..7f6d6fa6 100644
--- a/config/config_system_design.py
+++ b/config/config_system_design.py
@@ -521,25 +521,29 @@ FOREST_ROAD_PROFILE_CRITERIA = {
# 여기는 **제한**이라 서로 다르다. 둘을 한 값으로 묶으면 하한 0 이 곧 반지름 0 이 되어
# 곡선이 아예 안 그려진다.
# · None = 위 표(설계속도 × 지형)를 그대로 하한으로 쓴다.
- # · 0.0 = 제한 없음. 작업임도는 별표2에 곡선반지름 규정이 없어 열어 둔다 —
- # 값이 정해지면 **이 칸만** 고치면 서버·화면이 함께 따라간다.
+ # · 0.0 = 제한 없음.
+ # 작업임도는 별표2에 곡선반지름 규정이 없고 **실무값도 없다** — 자유도가 높은 공사라
+ # 사용자가 그때그때 정한다(2026-09-12 사용자 확정). 다만 화면에 하한을 적어 보여야 해서
+ # **5m** 를 둔다 — 작은 값으로 고칠 때 걸리적거리지 않는 수준으로 사용자가 고른 값이다.
+ # **이 칸만** 고치면 서버·화면이 함께 따라간다.
# ⚠ `projects.road_type` 은 main|fire|work 로 들어온다(B02 스키마) — 계획선 등급 코드
# trunk 와 같은 뜻이라 둘 다 적어 둔다. 없는 키는 None 과 같게(법정 표) 다뤄진다.
"plan_radius_limit_by_grade_m": {
"main": None,
"trunk": None,
"fire": None,
- "work": 0.0,
+ "work": 5.0,
"branch": None,
},
- # 평면 **곡선 길이(L)** 하한(m). 법령·교본에 값이 없어 지금은 전부 0(제한 없음)이다.
- # 자리만 만들어 두고, 실무값이 정해지면 여기에 적는다(2026-09-12 사용자 확정).
+ # 평면 **곡선 길이(L)** 하한(m). 법령·교본에 값이 없고 실무값도 없다 — 자유도가 높은
+ # 공사라 사용자가 정한다. 화면에 적어 보일 값으로 **5m** 를 둔다(2026-09-12 사용자 확정,
+ # R 하한과 같은 까닭). 여기만 고치면 서버·화면이 함께 따라간다.
"plan_curve_length_limit_by_grade_m": {
- "main": 0.0,
- "trunk": 0.0,
- "fire": 0.0,
- "work": 0.0,
- "branch": 0.0,
+ "main": 5.0,
+ "trunk": 5.0,
+ "fire": 5.0,
+ "work": 5.0,
+ "branch": 5.0,
},
# 임도 종류 → **기본** 설계속도(km/h). 임도는 속도를 낼 수 없는 노선이라 20이
# 기본이다(2026-08-19 사용자 확정). 별표2상 간선·산불진화는 20~40 범위에서
diff --git a/resources/tester/test_plan_curve_limits.py b/resources/tester/test_plan_curve_limits.py
index 09eb0db1..f335c267 100644
--- a/resources/tester/test_plan_curve_limits.py
+++ b/resources/tester/test_plan_curve_limits.py
@@ -17,15 +17,16 @@ def _default(grade_class: str, terrain: str) -> float:
return legal_plan_radius_min_m(resolve_design_speed(grade_class, None), terrain)
-def test_작업임도는_하한이_없다():
- """별표2에 작업임도 곡선반지름 규정이 없어 하한을 0(제한 없음)으로 열어 둔다."""
- assert plan_radius_limit_m("work", None, "normal") == 0.0
- assert plan_radius_limit_m("work", None, "special") == 0.0
+def test_작업임도_하한은_사용자가_고른_값이다():
+ """별표2에 작업임도 곡선반지름 규정이 없고 실무값도 없다 — 사용자가 5m 로 정했다
+ (2026-09-12). 지형과 무관한 한 값이라는 것까지 못박는다."""
+ assert plan_radius_limit_m("work", None, "normal") == 5.0
+ assert plan_radius_limit_m("work", None, "special") == 5.0
-def test_작업임도도_기본_반지름은_그대로다():
- """하한이 0이어도 **곡선을 만들 때 쓰는 기본값**은 살아 있어야 한다."""
- assert _default("work", "normal") > 0
+def test_기본_반지름과_하한은_다른_값이다():
+ """**곡선을 만들 때 쓰는 기본값**은 하한과 따로 산다 — 묶으면 하한이 곧 반지름이 된다."""
+ assert _default("work", "normal") > plan_radius_limit_m("work", None, "normal")
def test_간선_산불진화는_법정표가_곧_하한이다():
@@ -39,7 +40,8 @@ def test_모르는_임도종류는_법정표로_떨어진다():
assert plan_radius_limit_m("알 수 없는 종류", None, "normal") == _default("work", "normal")
-def test_곡선길이_하한은_아직_전부_0이다():
- """법령·교본에 평면 곡선 길이 하한 값이 없다 — 자리만 열어 둔 칸이다."""
- for grade_class in ("main", "trunk", "fire", "work", "branch", "없는종류"):
- assert plan_curve_length_limit_m(grade_class) == 0.0
+def test_곡선길이_하한은_임도_종류와_무관하게_같다():
+ """법령·교본에 값이 없어 사용자가 5m 로 정했다(2026-09-12). 없는 키는 0으로 떨어진다."""
+ for grade_class in ("main", "trunk", "fire", "work", "branch"):
+ assert plan_curve_length_limit_m(grade_class) == 5.0
+ assert plan_curve_length_limit_m("없는종류") == 0.0