Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_desktop_1
This commit is contained in:
@@ -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];
|
||||
/** 한 화면에 둘 등고선 가닥 수의 어림 상한 — 이보다 많으면 한 칸 성글게 간다. */
|
||||
|
||||
@@ -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(
|
||||
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
|
||||
<div class="b05-routeedit__head">
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__hint">
|
||||
노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 ·
|
||||
노드 오른쪽 클릭 = 삭제 · <b>측점 눈금 클릭 = 횡단 미리보기</b> ·
|
||||
<b>Shift+클릭 = 두 점 사이 거리·기울기</b> ·
|
||||
가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
|
||||
<span class="b05-routeedit__actions">
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
|
||||
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset">예상노선으로</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
|
||||
<div class="b05-routeedit__foot">
|
||||
<span class="b05-routeedit__status">노선을 읽는 중…</span>
|
||||
<span class="b05-routeedit__legend">
|
||||
<i class="is-expected"></i> 예상노선(원본)
|
||||
<i class="is-planned"></i> 계획노선
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="undo" title="되돌리기 (Ctrl+Z)"
|
||||
disabled>↶ 되돌리기</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="redo" title="다시하기 (Ctrl+Y)"
|
||||
disabled>↷ 다시하기</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="history-reset"
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="reset">예상노선으로</button>
|
||||
<button type="button" class="b05-routeedit__btn" data-act="cancel">취소</button>
|
||||
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply">확인</button>
|
||||
<div class="b05-routeedit__canvas-wrap">
|
||||
<canvas class="b05-routeedit__canvas"></canvas>
|
||||
<div class="b05-routeedit__hint">
|
||||
<span>노드 끌기 = 옮기기</span><span>노드 클릭 = R 라벨</span>
|
||||
<span>선 두 번 클릭 = 노드 추가</span><span>노드 오른쪽 클릭 = 삭제</span>
|
||||
<span>측점 눈금 클릭 = 횡단 미리보기</span><span>Shift+클릭 = 거리·기울기</span>
|
||||
<span>가운데(휠) 버튼 끌기 = 지도 이동</span><span>휠 = 확대</span>
|
||||
</div>
|
||||
<div class="b05-routeedit__spin">
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
|
||||
title="반시계로 돌리기">↺</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
|
||||
title="시계로 돌리기">↻</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__info">
|
||||
<span class="b05-routeedit__status">노선을 읽는 중…</span>
|
||||
<span class="b05-routeedit__legend">
|
||||
<i class="is-expected"></i> 예상노선(원본)
|
||||
<i class="is-planned"></i> 계획노선
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__busy" hidden><span></span></div>
|
||||
</div>`;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<RouteEditContours> {
|
||||
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 {
|
||||
/* 내려앉는다 — 아래 도엽 갈래로 이어 간다. */
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface CurveBarState {
|
||||
export interface CurveBarParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
state: () => CurveBarState;
|
||||
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
|
||||
applyEdit: (message: string) => void;
|
||||
|
||||
@@ -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<boolean>,
|
||||
curveRadius: Array<number | null>,
|
||||
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<EditedNode>,
|
||||
limitRadiusM: number,
|
||||
limitArcM: number,
|
||||
): number[] {
|
||||
export function curveShortfalls(nodes: ReadonlyArray<EditedNode>, 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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Vertex>;
|
||||
/** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 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 ⑤). */
|
||||
|
||||
@@ -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];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 범위에서
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user