fix(461 B05 종단 직선화): 클릭 = 늘 측점 고름 · 끝 자동 R 지움 기본 L · 이미 직선 범위 부분 구간 · 실시간 범위 띠
- 직선화 첫 클릭이 직선 안이면 직선 통째 고름(붉은 강조)으로 새던 갈림 지움 · 빈 곳 클릭 측점선에 붙음 - 이미 직선인 범위 = 부분 구간 고름(보라 띠 · 안내) · ▲▼ = 가운데 + 양 끝 기본 L 곡선 - 직선화 · 자유지정 끝 R 이 저장 자동 R 이면 지움 = 끝 곡선 L 20 · 사용자 R 은 둠 - 법정 미달 판정 오차 1e-3 짝(TS · 파이썬) — L 19.99998 거짓 미달 없앰 - 새 도우미 design_profile_range_band — 직선화 · 자유지정 범위 단계 실시간 띠 · 시작–끝 · 길이 글 - Esc = 시작만 취소 · 모드 유지 · 고름 풀기로 안 샘 - 시험 test_271_straighten_pick (거울 포함) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
type ChartWindow,
|
||||
type FreeDraw,
|
||||
} from "./logic/design_profile_free";
|
||||
import { appendRangeBand } from "./design_profile_range_band";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -100,29 +101,10 @@ export function createFreeView(free: FreeDraw): FreeView {
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
}) as SVGSVGElement;
|
||||
if (marks.band) {
|
||||
const [left, right] = marks.band.map(x);
|
||||
svg.append(
|
||||
svgEl("rect", {
|
||||
class: "b05-profile-freemark__band",
|
||||
x: left,
|
||||
y: 0,
|
||||
width: Math.max(0, right - left),
|
||||
height: heightPx,
|
||||
}),
|
||||
);
|
||||
// 범위 띠 · 세로선 · 길이 글 = [직선화]와 한 벌(271-3) — 고르는 중엔 마우스 측점까지
|
||||
if (marks.edges.length) {
|
||||
appendRangeBand(svg, x, heightPx, marks.edges[0], marks.edges[1] ?? null);
|
||||
}
|
||||
marks.edges.forEach((chainage) =>
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: "b05-profile-freemark__edge",
|
||||
x1: x(chainage),
|
||||
x2: x(chainage),
|
||||
y1: 0,
|
||||
y2: heightPx,
|
||||
}),
|
||||
),
|
||||
);
|
||||
marks.segments.forEach((segment) => {
|
||||
const x1 = x(segment.from.chainage_m);
|
||||
const x2 = x(segment.to.chainage_m);
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_profile_range_band.ts
|
||||
* 종단 범위 실시간 띠 — 시작 세로선을 고른 뒤 마우스 측점까지 띠 · 세로선 · 「시작–끝 · 길이」 글(271-2 · 3).
|
||||
*
|
||||
* [직선화]는 `createRangeBandView` 로 그래프에 붙이고, [자유지정]은 `design_profile_free_view` 가
|
||||
* 같은 `appendRangeBand` 로 그린다 — 띠 한 벌. 마우스 자리는 가장 가까운 측점선에 붙음 · 그래프 밖 =
|
||||
* 시작선만 · 다시 들어오면 이어짐. 색 · 굵기는 자유지정 범위 띠(`b05-profile-freemark__*`)와 같음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { snapStation } from "./logic/design_profile_free";
|
||||
import type { FreeChartGeom } from "./design_profile_free_view";
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function svgEl(tag: string, attrs: Record<string, string | number>): SVGElement {
|
||||
const element = document.createElementNS(SVG_NS, tag);
|
||||
Object.entries(attrs).forEach(([name, value]) => element.setAttribute(name, String(value)));
|
||||
return element;
|
||||
}
|
||||
|
||||
/** 범위 띠를 붙인다 — 끝이 없거나 시작과 같으면 시작선만 · 반대 방향도 같은 띠. */
|
||||
export function appendRangeBand(
|
||||
svg: SVGElement,
|
||||
x: (chainageM: number) => number,
|
||||
heightPx: number,
|
||||
startM: number,
|
||||
endM: number | null,
|
||||
): void {
|
||||
const ends =
|
||||
endM === null || Math.abs(endM - startM) < 1e-6
|
||||
? [startM]
|
||||
: [Math.min(startM, endM), Math.max(startM, endM)];
|
||||
if (ends.length === 2) {
|
||||
const [left, right] = ends.map(x);
|
||||
svg.append(
|
||||
svgEl("rect", {
|
||||
class: "b05-profile-freemark__band",
|
||||
x: left,
|
||||
y: 0,
|
||||
width: Math.max(0, right - left),
|
||||
height: heightPx,
|
||||
}),
|
||||
);
|
||||
const label = svgEl("text", {
|
||||
class: "b05-profile-freemark__span",
|
||||
x: (left + right) / 2,
|
||||
y: 14,
|
||||
"text-anchor": "middle",
|
||||
});
|
||||
label.textContent = `${ends[0].toFixed(1)}–${ends[1].toFixed(1)}m · ${(ends[1] - ends[0]).toFixed(1)}m`;
|
||||
svg.append(label);
|
||||
}
|
||||
ends.forEach((chainage) =>
|
||||
svg.append(
|
||||
svgEl("line", {
|
||||
class: "b05-profile-freemark__edge",
|
||||
x1: x(chainage),
|
||||
x2: x(chainage),
|
||||
y1: 0,
|
||||
y2: heightPx,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export interface RangeBandView {
|
||||
/** 그래프를 새로 그렸거나 모드가 바뀜 — 켜져 있으면 붙이고, 꺼져 있으면 뗀다. */
|
||||
mount: (geom: FreeChartGeom | null, active: boolean) => void;
|
||||
redraw: () => void;
|
||||
}
|
||||
|
||||
/** 범위 띠 화면 — `source(마우스 측점)` 가 [시작, 끝 | null] 을 주면 그림 · null 이면 지움. */
|
||||
export function createRangeBandView(
|
||||
source: (hoverM: number | null) => [number, number | null] | null,
|
||||
): RangeBandView {
|
||||
let geom: FreeChartGeom | null = null;
|
||||
let active = false;
|
||||
let hover: number | null = null;
|
||||
/** 마지막 마우스 x(화면) — 스크롤 · 다시 그림 뒤에도 커서 밑 측점을 다시 잡는다. */
|
||||
let clientX: number | null = null;
|
||||
let overlay: SVGElement | null = null;
|
||||
let listeners: AbortController | null = null;
|
||||
|
||||
function redraw(): void {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
const range = geom && active ? source(hover) : null;
|
||||
if (!geom || !range) return;
|
||||
const { widthPx, heightPx } = geom;
|
||||
overlay = svgEl("svg", {
|
||||
class: "b05-profile-freemark",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
});
|
||||
appendRangeBand(overlay, geom.x, heightPx, range[0], range[1]);
|
||||
geom.chart.append(overlay);
|
||||
}
|
||||
|
||||
/** 커서 밑 측점을 다시 잡는다 — 바뀌었을 때만 다시 그림. */
|
||||
function track(): void {
|
||||
let next: number | null = null;
|
||||
if (geom && clientX !== null) {
|
||||
const at = geom.toChainage(clientX - geom.chart.getBoundingClientRect().left);
|
||||
next = Number.isFinite(at) ? snapStation(geom.stations, at) : null;
|
||||
}
|
||||
if (next === hover) return;
|
||||
hover = next;
|
||||
redraw();
|
||||
}
|
||||
|
||||
function mount(next: FreeChartGeom | null, on: boolean): void {
|
||||
listeners?.abort();
|
||||
listeners = null;
|
||||
if (next) geom = next;
|
||||
active = on;
|
||||
if (!on) [hover, clientX] = [null, null];
|
||||
if (!geom || !on || !geom.chart.isConnected) {
|
||||
overlay?.remove();
|
||||
overlay = null;
|
||||
return;
|
||||
}
|
||||
listeners = new AbortController();
|
||||
const { signal } = listeners;
|
||||
const chart = geom.chart;
|
||||
chart.addEventListener(
|
||||
"mousemove",
|
||||
(event) => {
|
||||
clientX = event.clientX;
|
||||
track();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
chart.addEventListener(
|
||||
"mouseleave",
|
||||
() => {
|
||||
clientX = null;
|
||||
track();
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
geom.scroller.addEventListener("scroll", track, { signal, passive: true });
|
||||
hover = null;
|
||||
track();
|
||||
redraw();
|
||||
}
|
||||
|
||||
return { mount, redraw };
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
||||
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) =
|
||||
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소(범위 첫 측점만 = 시작만 · 271-3) · ↶ ↷(Backspace = ↶) =
|
||||
* 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음.
|
||||
*
|
||||
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import {
|
||||
baseElevationAt,
|
||||
defaultCurveLength,
|
||||
dropAutoEndRadii,
|
||||
pinEnds,
|
||||
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
|
||||
import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight";
|
||||
@@ -120,12 +121,14 @@ function elevationOnPath(path: FreeVertex[], chainageM: number): number {
|
||||
|
||||
/**
|
||||
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
|
||||
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
|
||||
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음) ·
|
||||
* 끝 R 이 저장 자동 R 이면 지워 기본 L(271-1).
|
||||
*/
|
||||
export function buildFreeEdits(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
vertices: FreeVertex[],
|
||||
autoRadii: Readonly<Record<string, number>> = {},
|
||||
): AlignmentEdits {
|
||||
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
if (path.length < 2) return edits;
|
||||
@@ -148,6 +151,7 @@ export function buildFreeEdits(
|
||||
pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => {
|
||||
offsets[key] = Number(value.toFixed(6));
|
||||
});
|
||||
dropAutoEndRadii(radii, [fromM, toM], autoRadii);
|
||||
inner.forEach((chainage, key) => {
|
||||
const target = elevationOnPath(path, chainage);
|
||||
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
|
||||
@@ -191,6 +195,8 @@ export interface FreeDrawDeps {
|
||||
edits: () => AlignmentEdits;
|
||||
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */
|
||||
apply: (next: AlignmentEdits) => boolean | void;
|
||||
/** 저장 자동 R — 끝 R 이 이것과 같으면 지움(271-1). */
|
||||
autoRadii?: () => Readonly<Record<string, number>>;
|
||||
/** 모드를 끈다(적용 뒤 · Esc). */
|
||||
close: () => void;
|
||||
/** 단계 · 안내가 바뀜. */
|
||||
@@ -222,6 +228,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
let anchor: number | null = null;
|
||||
let points: FreeVertex[] = [];
|
||||
let target: FreeVertex | null = null;
|
||||
/** range 단계 첫 측점 뒤 마우스 측점 — 실시간 띠 끝(271-3). */
|
||||
let hoverStation: number | null = null;
|
||||
/** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */
|
||||
interface Snap {
|
||||
phase: FreePhase;
|
||||
@@ -249,6 +257,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
anchor = null;
|
||||
points = [];
|
||||
target = null;
|
||||
hoverStation = null;
|
||||
past = [];
|
||||
future = [];
|
||||
}
|
||||
@@ -296,7 +305,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
const z = end === null ? null : heightAt(end);
|
||||
if (!base || end === null || z === null) return;
|
||||
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices);
|
||||
const next = buildFreeEdits(base, deps.edits(), vertices, deps.autoRadii?.());
|
||||
// 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3)
|
||||
if (deps.apply(next) === false) return;
|
||||
reset();
|
||||
@@ -343,8 +352,19 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
},
|
||||
hover(pointer) {
|
||||
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
|
||||
hoverStation =
|
||||
pointer && phase === "range" && pending !== null
|
||||
? snapStation(pointer.stations, pointer.chainageM)
|
||||
: null;
|
||||
},
|
||||
key(key) {
|
||||
// range 단계 첫 측점만 고른 채 Esc = 시작만 취소 · 모드 유지(271-3)
|
||||
if (key === "Escape" && phase === "range" && pending !== null) {
|
||||
pending = null;
|
||||
hoverStation = null;
|
||||
deps.changed();
|
||||
return true;
|
||||
}
|
||||
if (key === "Escape") {
|
||||
reset();
|
||||
deps.close();
|
||||
@@ -381,9 +401,15 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
preview: target !== null && index === line.length - 1,
|
||||
});
|
||||
}
|
||||
// 범위 고르는 중엔 첫 측점 → 마우스 측점까지 띠(271-3) · 둘째 클릭 뒤엔 고른 범위로 고정
|
||||
const live =
|
||||
pending !== null && hoverStation !== null && !same(pending, hoverStation)
|
||||
? ([Math.min(pending, hoverStation), Math.max(pending, hoverStation)] as [number, number])
|
||||
: null;
|
||||
const band = range ?? live;
|
||||
return {
|
||||
band: range,
|
||||
edges: range ? [...range] : pending !== null ? [pending] : [],
|
||||
band,
|
||||
edges: band ? [...band] : pending !== null ? [pending] : [],
|
||||
segments,
|
||||
dots,
|
||||
};
|
||||
@@ -392,7 +418,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
||||
if (phase === "range") {
|
||||
return pending === null
|
||||
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
||||
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요 (Esc 시작 취소)`;
|
||||
}
|
||||
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
||||
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)";
|
||||
|
||||
@@ -18,6 +18,8 @@ from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
|
||||
|
||||
# 초기값 직선 문턱 R(m) — TS `INITIAL_STRAIGHT_RADIUS_M` 과 같은 값.
|
||||
INITIAL_STRAIGHT_RADIUS_M = float(FOREST_ROAD_PROFILE_ALIGNMENT["initial_straight_radius_m"])
|
||||
# 법정 미달 판정 오차(m) — R = L/Δ 되셈 L 19.99998 거짓 미달 막음(271-1) · TS `SHORTFALL_TOLERANCE_M` 짝.
|
||||
SHORTFALL_TOLERANCE_M = 1e-3
|
||||
|
||||
|
||||
def flat_bends(base_s: np.ndarray, base_z: np.ndarray, curve_length_m: float | None) -> np.ndarray:
|
||||
@@ -60,7 +62,10 @@ def vertical_curve_shortfalls(
|
||||
continue
|
||||
radius = float(curve.get("radius_m", curve.get("r_m", 0.0)))
|
||||
length = float(curve.get("length_m", curve.get("l_m", 0.0)))
|
||||
if radius < min_radius_m - 1e-6 or length < min_length_m - 1e-6:
|
||||
if (
|
||||
radius < min_radius_m - SHORTFALL_TOLERANCE_M
|
||||
or length < min_length_m - SHORTFALL_TOLERANCE_M
|
||||
):
|
||||
out.append(
|
||||
f"{float(curve['chainage_m']):.1f}m: 종단곡선 R {radius:.0f}m · L {length:.1f}m — "
|
||||
f"법정 최소 R {min_radius_m:.0f}m · L {min_length_m:.0f}m 미달"
|
||||
|
||||
@@ -29,11 +29,13 @@ import {
|
||||
hasStraightRuns,
|
||||
shiftStraightRuns,
|
||||
straightenBetween,
|
||||
straightSubRun,
|
||||
tiltStraightRun,
|
||||
} from "./logic/B05_Profile_UI_Profile_Straighten";
|
||||
import type { IrregularStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import { createFreeDraw } from "../A00_Common/design/logic/design_profile_free";
|
||||
import { createFreeDraw, snapStation } from "../A00_Common/design/logic/design_profile_free";
|
||||
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
|
||||
import { createRangeBandView } from "../A00_Common/design/design_profile_range_band";
|
||||
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
|
||||
import { stepStructureStation } from "../common_util/common_util_structure_station";
|
||||
import { draftProjectId, useDesignStore } from "../A00_Common/design/design_store";
|
||||
@@ -121,17 +123,35 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
alignment: ctx.alignment,
|
||||
edits: ctx.edits,
|
||||
apply: (next) => ctx.applyEdits(next),
|
||||
autoRadii: ctx.savedRadii,
|
||||
close: () => tools.clear(),
|
||||
changed: ctx.refresh,
|
||||
});
|
||||
const freeView = createFreeView(free);
|
||||
let lastGeom: FreeChartGeom | null = null;
|
||||
// [직선화] 실시간 띠(271-2) — 시작 측점 → 마우스 측점 · 고른 부분 구간은 고정 띠.
|
||||
const bandView = createRangeBandView((hoverM) => {
|
||||
const start = tools.pendingStation();
|
||||
const sub = tools.subRun();
|
||||
return start !== null ? [start, hoverM] : sub ? [sub.fromM, sub.toM] : null;
|
||||
});
|
||||
const mountViews = (mode: string): void => {
|
||||
freeView.mount(lastGeom, mode === "free");
|
||||
bandView.mount(lastGeom, mode === "straighten");
|
||||
};
|
||||
|
||||
const tools = createProfileTools({
|
||||
onStraighten: (fromM, toM) => {
|
||||
const base = ctx.base();
|
||||
if (!base) return;
|
||||
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM));
|
||||
// 이미 직선인 범위 = 부분 구간으로 고름(271-1) — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선
|
||||
const alignment = ctx.alignment();
|
||||
const sub = alignment ? straightSubRun(alignment, fromM, toM) : null;
|
||||
if (sub) {
|
||||
tools.handleRunPick(sub);
|
||||
return;
|
||||
}
|
||||
ctx.applyEdits(straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii()));
|
||||
tools.clear();
|
||||
},
|
||||
onShift: (runs, delta) => {
|
||||
@@ -159,12 +179,12 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
const alignment = ctx.alignment();
|
||||
return alignment ? hasStraightRuns(alignment) : false;
|
||||
},
|
||||
onChanged: ctx.refresh,
|
||||
onModeChange: (mode) => freeView.mount(lastGeom, mode === "free"),
|
||||
onChanged: () => (bandView.redraw(), ctx.refresh()),
|
||||
onModeChange: mountViews,
|
||||
freeHint: () => free.hint(),
|
||||
mountOverlay: (geom) => {
|
||||
lastGeom = geom;
|
||||
freeView.mount(geom, tools.mode() === "free");
|
||||
mountViews(tools.mode());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -178,15 +198,10 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
if (mode === "shift") {
|
||||
return tools.handleRunPick(alignment ? detectStraightRun(alignment, chainageM) : null);
|
||||
}
|
||||
// 직선화 모드에서 첫 클릭이 **이미 직선화된 라인 안쪽**이면 그 라인을 골라
|
||||
// 틸팅(가운데 라운드 + 양측 탄젠트) 대상으로 삼는다.
|
||||
if (tools.pendingStation() === null && alignment) {
|
||||
const run = detectStraightRun(alignment, chainageM);
|
||||
if (run && Math.abs(run.fromM - chainageM) > 1e-6 && Math.abs(run.toM - chainageM) > 1e-6) {
|
||||
return tools.handleRunPick(run);
|
||||
}
|
||||
}
|
||||
return tools.handleStationPick(chainageM);
|
||||
// 직선화 클릭 = 늘 측점 고름 · 빈 곳은 가장 가까운 측점선에 붙음(271-1) — 직선 안 클릭이
|
||||
// 직선 통째 고름(붉은 강조)으로 새던 갈림을 지움. 이미 직선인 범위는 `onStraighten` 이 가름.
|
||||
const station = lastGeom ? snapStation(lastGeom.stations, chainageM) : null;
|
||||
return tools.handleStationPick(station ?? chainageM);
|
||||
}
|
||||
|
||||
function selectedIrregular(): IrregularStation | null {
|
||||
@@ -355,10 +370,33 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
|
||||
/* Esc = 도구 몫(271-2 · 3) — 잡는 단계에서 먼저 받아 고름 풀기(`design_pick_escape`)로 안 샘:
|
||||
* [직선화] 시작 측점 · 부분 구간만 취소(모드 유지) · [자유지정]은 자기 규칙(범위 첫 측점만 = 시작만). */
|
||||
const onEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key !== "Escape" || !ctx.root.isConnected) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (
|
||||
target?.closest?.("input, select, textarea") ||
|
||||
document.querySelector(".ui-modal__backdrop")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (tools.mode() === "free") {
|
||||
free.key("Escape");
|
||||
freeView.redraw();
|
||||
} else if (!(tools.mode() === "straighten" && tools.cancelPick())) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
window.addEventListener("keydown", onEscape, true);
|
||||
|
||||
const setListening = (on: boolean): void => {
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
if (on) window.addEventListener("keydown", onKeyDown);
|
||||
else stopHold();
|
||||
window.removeEventListener("keydown", onEscape, true);
|
||||
if (on) {
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keydown", onEscape, true);
|
||||
} else stopHold();
|
||||
};
|
||||
return { tools, history, handleToolPick, setListening };
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* [새 창] 과 같다(공용 토큰 `--btn-bar-*`). [새 창] 은 종단도가 그릴 때마다 맨 앞에 옮겨 붙인다(282-2).
|
||||
*
|
||||
* 선택 흐름:
|
||||
* [직선화] → 측점 2개를 그래프에서 고름 → 두 측점의 라운드에 탄젠트한 직선으로 대체.
|
||||
* [직선화] → 측점 2개를 그래프에서 고름(클릭 = 늘 측점 · 271-1) → 두 측점의 라운드에 탄젠트한 직선으로 대체.
|
||||
* 고른 범위가 이미 직선이면 그 범위를 부분 구간으로 고름 → [▲][▼]로 꺾으면 가운데에
|
||||
* 라운드 · 양 끝에 기본 L 곡선. Esc = 시작(또는 부분 구간)만 취소 · 모드 유지.
|
||||
* [쉬프트] → 직선화된 라인을 고름(여러 개 가능) → [▲][▼]로 통째로 올리고 내림.
|
||||
* 직선을 고른 채 [직선화] 모드에서 [▲][▼]를 누르면 그 직선이 꺾이며 가운데에
|
||||
* 라운드가 생기고 양쪽이 탄젠트 직선이 된다.
|
||||
* [자유지정] → 범위를 고르고 클릭으로 계획선을 잇는다(`design_profile_free` 몫 · 215-1).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -55,8 +55,12 @@ export interface ProfileTools {
|
||||
handleStationPick: (chainageM: number) => boolean;
|
||||
/** 그래프에서 직선을 눌렀을 때(구간 판정 결과) — 도구가 삼켰으면 true. */
|
||||
handleRunPick: (run: StraightRun | null) => boolean;
|
||||
/** 선택 중인 직선 구간들(강조 표시용). */
|
||||
/** [쉬프트]로 고른 직선 구간들(붉은 강조용) — [직선화] 부분 구간은 `subRun`(보라 띠 · 271-1). */
|
||||
selectedRuns: () => StraightRun[];
|
||||
/** [직선화] 중 고른 이미 직선인 부분 구간(▲▼ 꺾기 대상). */
|
||||
subRun: () => StraightRun | null;
|
||||
/** [직선화] 시작 측점 · 부분 구간만 취소(Esc · 모드 유지) — 취소한 것이 있으면 true. */
|
||||
cancelPick: () => boolean;
|
||||
/** 직선화 대기 중 첫 측점(강조 표시용). */
|
||||
pendingStation: () => number | null;
|
||||
/** 고른 직선을 ▲▼ 버튼과 같은 경로로 옮긴다(방향키가 같이 쓴다) — 대상이 없으면 false. */
|
||||
@@ -117,7 +121,15 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
return {
|
||||
mode: () => mode,
|
||||
nudge: step,
|
||||
selectedRuns: () => runs,
|
||||
selectedRuns: () => (mode === "shift" ? runs : []),
|
||||
subRun: () => (mode === "straighten" ? (runs[0] ?? null) : null),
|
||||
cancelPick() {
|
||||
if (mode !== "straighten" || (pending === null && !runs.length)) return false;
|
||||
pending = null;
|
||||
runs = [];
|
||||
callbacks.onChanged();
|
||||
return true;
|
||||
},
|
||||
pendingStation: () => pending,
|
||||
mountOverlay: callbacks.mountOverlay,
|
||||
clear() {
|
||||
@@ -129,6 +141,7 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
if (mode !== "straighten") return false;
|
||||
if (pending === null) {
|
||||
pending = chainageM;
|
||||
runs = [];
|
||||
callbacks.onChanged();
|
||||
return true;
|
||||
}
|
||||
@@ -222,7 +235,12 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
|
||||
} else if (mode === "straighten" && pending !== null) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (취소는 [직선화] 다시 누르기)`;
|
||||
hint.textContent = `${pending.toFixed(1)}m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)`;
|
||||
wrap.append(hint);
|
||||
} else if (mode === "straighten" && runs.length === 1) {
|
||||
const hint = document.createElement("em");
|
||||
hint.className = "b05-route-profile__tool-hint";
|
||||
hint.textContent = "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선";
|
||||
wrap.append(hint);
|
||||
} else if (mode === "shift" && !runs.length) {
|
||||
const hint = document.createElement("em");
|
||||
|
||||
@@ -358,6 +358,15 @@
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
/* 범위 「시작–끝 · 길이」 글 — [직선화] · [자유지정] 실시간 띠 위(271-2 · 3). */
|
||||
.b05-profile-freemark__span {
|
||||
fill: var(--color-primary);
|
||||
font-size: var(--text-caption);
|
||||
paint-order: stroke;
|
||||
stroke: var(--color-surface);
|
||||
stroke-width: 3px;
|
||||
}
|
||||
|
||||
/* 지나온 선 · 미리 보기 · 점 = 녹색(367-4) — 범위 띠 · 경계는 보라 그대로. */
|
||||
.b05-profile-freemark__line {
|
||||
stroke: var(--color-success);
|
||||
|
||||
@@ -18,6 +18,8 @@ import type { AlignmentCurve, AlignmentPolicy } from "./B05_Profile_UI_Profile_A
|
||||
export const DEFAULT_CURVE_LENGTH_M = 20;
|
||||
/** 초기값 직선 문턱 R(m) — config `initial_straight_radius_m` 짝. */
|
||||
export const INITIAL_STRAIGHT_RADIUS_M = 500;
|
||||
/** 법정 미달 판정 오차(m) — R = L/Δ 를 반올림 높이로 되셈한 L 19.99998 거짓 미달 막음(271-1) · 파이썬 짝. */
|
||||
export const SHORTFALL_TOLERANCE_M = 1e-3;
|
||||
|
||||
/** 안쪽 꼭짓점 중 기본 L 기준 R > 500 m(|대수차| < L/500) 인 것 — 파이썬 `flat_bends` · 시종점 false. */
|
||||
export function flatBends(
|
||||
@@ -72,7 +74,10 @@ export function verticalCurveShortfalls(
|
||||
if (minR <= 0 && minL <= 0) return [];
|
||||
return curves
|
||||
.filter((curve) => !curve.omitted && !curve.skip_allowed)
|
||||
.filter((curve) => curve.r_m < minR - 1e-6 || curve.l_m < minL - 1e-6)
|
||||
.filter(
|
||||
(curve) =>
|
||||
curve.r_m < minR - SHORTFALL_TOLERANCE_M || curve.l_m < minL - SHORTFALL_TOLERANCE_M,
|
||||
)
|
||||
.map(
|
||||
(curve) =>
|
||||
`${curve.chainage_m.toFixed(1)}m: 종단곡선 R ${curve.r_m.toFixed(0)}m · L ${curve.l_m.toFixed(1)}m — ` +
|
||||
|
||||
@@ -109,6 +109,24 @@ export function hasStraightRuns(alignment: ProfileAlignment): boolean {
|
||||
return alignment.pvi.some((_node, index) => straightAt(alignment.pvi, index));
|
||||
}
|
||||
|
||||
/**
|
||||
* 고른 범위가 이미 한 직선이면 그 범위를 부분 구간으로 돌려준다(271-1) — [직선화] ▲▼ 로 꺾으면
|
||||
* 양 끝에 기본 L 곡선. 사이 꼭짓점이 없거나 하나라도 꺾였으면 null(보통 직선화).
|
||||
*/
|
||||
export function straightSubRun(
|
||||
alignment: ProfileAlignment,
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
): StraightRun | null {
|
||||
const fromM = Math.min(fromChainageM, toChainageM);
|
||||
const toM = Math.max(fromChainageM, toChainageM);
|
||||
const inside = alignment.pvi
|
||||
.map((node, index) => ({ at: node.chainage_m, index }))
|
||||
.filter(({ at }) => at > fromM + SAME_STATION_M && at < toM - SAME_STATION_M);
|
||||
if (!inside.length || !inside.every(({ index }) => straightAt(alignment.pvi, index))) return null;
|
||||
return { fromM, toM, nodes: [fromM, ...inside.map(({ at }) => at), toM] };
|
||||
}
|
||||
|
||||
/** 두 측점 사이(끝 제외)의 변화점 chainage. */
|
||||
function interiorNodes(alignment: ProfileAlignment, fromM: number, toM: number): number[] {
|
||||
return pviChainages(alignment).filter(
|
||||
@@ -131,17 +149,33 @@ function withOffsets(
|
||||
return { station_offsets: stationOffsets, curve_radii: curveRadii };
|
||||
}
|
||||
|
||||
/**
|
||||
* 범위 끝 R 이 저장 자동 R 과 같으면 지운다 — 끝 곡선 = 기본 L(271-1 · ▲▼ 433-13 과 같은 규칙) ·
|
||||
* 사용자 R 은 둠. `radii` 를 고쳐 쓴다.
|
||||
*/
|
||||
export function dropAutoEndRadii(
|
||||
radii: Record<string, number>,
|
||||
ends: number[],
|
||||
autoRadii: Readonly<Record<string, number>>,
|
||||
): void {
|
||||
ends.map(chainageKey).forEach((key) => {
|
||||
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 측점을 직선으로 잇는다 — 사이 측점을 그 직선 위로 옮기고 라운드를 지운다.
|
||||
*
|
||||
* 양 끝 측점의 표고는 건드리지 않는다. 그 자리에 라운드가 있으면 새 직선이 그 라운드의
|
||||
* 접선이 된다(변화점 z는 그대로이고 좌우 직선만 바뀌므로 자동으로 성립한다).
|
||||
* 끝 R 이 저장 자동 R(`autoRadii`)이면 지워 기본 L(271-1).
|
||||
*/
|
||||
export function straightenBetween(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
fromChainageM: number,
|
||||
toChainageM: number,
|
||||
autoRadii: Readonly<Record<string, number>> = {},
|
||||
): AlignmentEdits {
|
||||
const fromM = Math.min(fromChainageM, toChainageM);
|
||||
const toM = Math.max(fromChainageM, toChainageM);
|
||||
@@ -156,11 +190,13 @@ export function straightenBetween(
|
||||
const target = startZ + ((endZ - startZ) * (chainage - fromM)) / span;
|
||||
return [chainageKey(chainage), target - baseElevationAt(base, chainage)];
|
||||
});
|
||||
return withOffsets(
|
||||
const next = withOffsets(
|
||||
edits,
|
||||
[...updates, ...pinEnds(base, current, [fromM, toM])],
|
||||
inner.map(chainageKey),
|
||||
);
|
||||
dropAutoEndRadii(next.curve_radii, [fromM, toM], autoRadii);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,6 +281,8 @@ export function tiltStraightRun(
|
||||
if (chainage < pivot) place(chainage, run.fromM, startZ, pivot, pivotZ);
|
||||
else place(chainage, pivot, pivotZ, run.toM, endZ);
|
||||
});
|
||||
// 부분 구간(271-1) 끝이 꼭짓점 아닌 측점이면 지금 높이로 세움 — 끝이 따라 움직이지 않게
|
||||
updates.push(...pinEnds(base, current, [run.fromM, run.toM]));
|
||||
|
||||
const gradeIn = (pivotZ - startZ) / Math.max(pivot - run.fromM, 1e-12);
|
||||
const gradeOut = (endZ - pivotZ) / Math.max(run.toM - pivot, 1e-12);
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/* 271-1~3 종단 직선화 측점 고름 · 끝 R 기본 L · 실시간 띠 단위검증 헬퍼 — `_Straighten.ts` ·
|
||||
* `_Tools.ts` · `design_profile_free.ts` · `design_profile_range_band.ts` 를 그 자리에서 트랜스파일해
|
||||
* 돌리고 파이썬 거울 입력(base · edits · TS 곡선)을 JSON 으로 낸다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
// 화면 틀 파일(@ui 별칭)은 Node 가 못 읽음 — 도구줄 그림만 쓰므로 빈 그림으로 갈음
|
||||
const STUBS = { "./B05_Profile_UI_RouteEdit_Chrome": { HISTORY_ICON: { undo: "", redo: "" } } };
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
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 near = (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, near);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
||||
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const st = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Straighten.ts"));
|
||||
const law = loadTs(path.join(LOGIC, "B05_Profile_Profile_CurveLaw.ts"));
|
||||
const tools = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Tools.ts"));
|
||||
const DESIGN = path.join(ROOT, "A00_Common", "design");
|
||||
const free = loadTs(path.join(DESIGN, "logic", "design_profile_free.ts"));
|
||||
const band = loadTs(path.join(DESIGN, "design_profile_range_band.ts"));
|
||||
|
||||
const policy = {
|
||||
station_interval_m: 20,
|
||||
curve_radius_ratio: 1,
|
||||
curve_tangent_max_ratio: 0.5,
|
||||
curve_skip_legal_exception: false,
|
||||
default_curve_length_m: 20,
|
||||
default_curve_radius_m: 300,
|
||||
balance_tolerance_percent: 10,
|
||||
edit_step_m: 0.1,
|
||||
grade_violation_policy: "warn",
|
||||
max_grade_pct: 14,
|
||||
curve_skip_delta_pct: 0,
|
||||
paved: true,
|
||||
min_vertical_radius_m: 100,
|
||||
min_curve_length_m: 20,
|
||||
};
|
||||
// 0~300 m · 20 m 측점 — 마디마다 기울기가 다른 전체 측점 폴리라인(서버와 같이 지정 없는 꺾임 = 기본 L)
|
||||
const S = Array.from({ length: 16 }, (_, i) => i * 20);
|
||||
const g = (s) => 100 + 0.03 * s + 0.6 * Math.sin(s / 13);
|
||||
const base = {
|
||||
policy,
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: S.map((s) => ({ chainage_m: s, elevation_m: +g(s).toFixed(6) })),
|
||||
chainage: S,
|
||||
ground: S.map((s) => +g(s).toFixed(6)),
|
||||
stations: S.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
|
||||
};
|
||||
const tidy = (e) => st.tidyEdits(base, e);
|
||||
const build = (e) => align.buildAlignment(base, e);
|
||||
const curves = (e) => build(e).curves.map((c) => ({ at: c.chainage_m, l: c.l_m, r: c.r_m }));
|
||||
const shortfalls = (e) => build(e).warnings.filter((w) => w.includes("미달"));
|
||||
|
||||
const out = {};
|
||||
const mirror = [];
|
||||
const keep = (name, edits) => {
|
||||
mirror.push({ name, edits, curves: curves(edits).map((c) => [c.at, c.r, c.l]) });
|
||||
return edits;
|
||||
};
|
||||
|
||||
// ① 직선화 40~200 — 사이 곡선 없음 · 양 끝 기본 L 20
|
||||
const e1 = keep("straight", tidy(st.straightenBetween(base, align.emptyEdits(), 40, 200)));
|
||||
const a1 = build(e1);
|
||||
out.straight = { curves: curves(e1), radii: Object.keys(e1.curve_radii) };
|
||||
|
||||
// ② 이미 직선인 범위 = 부분 구간 · 반대 방향 같음 · 꺾인 · 섞인 범위 = null
|
||||
out.sub = st.straightSubRun(a1, 80, 160);
|
||||
out.subReverse = st.straightSubRun(a1, 160, 80);
|
||||
out.subBent = st.straightSubRun(build(align.emptyEdits()), 40, 200);
|
||||
out.subMixed = st.straightSubRun(a1, 100, 260);
|
||||
out.subOneSpan = st.straightSubRun(a1, 80, 100);
|
||||
|
||||
// ③ 부분 구간 ▲(틸트) = 가운데 + 양 끝 L 20 · 거짓 미달 없음
|
||||
const e3 = keep("tiltSub", tidy(st.tiltStraightRun(base, e1, out.sub, 0.5)));
|
||||
out.tiltSub = { curves: curves(e3), shortfalls: shortfalls(e3) };
|
||||
// 판정 오차 짝(1e-3) — 옛 1e-6 이면 L 19.99998 이 미달로 읽힘
|
||||
out.tolerance = law.SHORTFALL_TOLERANCE_M;
|
||||
out.nearMiss = law.verticalCurveShortfalls(
|
||||
[{ chainage_m: 120, l_m: 19.99998, r_m: 800, omitted: false, skip_allowed: false }],
|
||||
policy,
|
||||
);
|
||||
out.realMiss = law.verticalCurveShortfalls(
|
||||
[{ chainage_m: 120, l_m: 19.9, r_m: 800, omitted: false, skip_allowed: false }],
|
||||
policy,
|
||||
);
|
||||
|
||||
// ④ 끝에 저장 자동 R → 지움(기본 L 20) · 사용자 R 은 둠
|
||||
const saved = { "40.000": 5330, "200.000": 1084 };
|
||||
const savedEdits = { station_offsets: {}, curve_radii: { ...saved } };
|
||||
const e4 = keep("autoEnds", tidy(st.straightenBetween(base, savedEdits, 40, 200, saved)));
|
||||
out.autoEnds = { radii: Object.keys(e4.curve_radii), curves: curves(e4) };
|
||||
const userEdits = { station_offsets: {}, curve_radii: { "40.000": 600, "200.000": 1084 } };
|
||||
const e4u = tidy(st.straightenBetween(base, userEdits, 40, 200, saved));
|
||||
out.userEnd = { radii: e4u.curve_radii };
|
||||
// 자동 R 기준 없이(옛 부름) = 그대로 남음
|
||||
out.noAuto = Object.keys(tidy(st.straightenBetween(base, savedEdits, 40, 200)).curve_radii);
|
||||
|
||||
// ⑤ BP · EP — 시종점 곡선 없음
|
||||
const e5 = keep("bpEp", tidy(st.straightenBetween(base, align.emptyEdits(), 0, 300)));
|
||||
out.bpEp = curves(e5);
|
||||
const eBP = tidy(st.straightenBetween(base, align.emptyEdits(), 0, 200));
|
||||
const subBP = st.straightSubRun(build(eBP), 0, 80);
|
||||
const e5t = keep("tiltBp", tidy(st.tiltStraightRun(base, eBP, subBP, 0.5)));
|
||||
out.tiltBp = curves(e5t).map((c) => c.at);
|
||||
|
||||
// ⑥ 곡선 섞인 범위 = 안쪽 R 지움 · 양 끝 L 20
|
||||
const mixedIn = { station_offsets: {}, curve_radii: { "120.000": 900, "140.000": 700 } };
|
||||
const e6 = keep("mixed", tidy(st.straightenBetween(base, mixedIn, 100, 260, {})));
|
||||
out.mixed = { radii: Object.keys(e6.curve_radii), curves: curves(e6) };
|
||||
|
||||
// ⑦ 자유지정 끝 자동 R 지움 · 사용자 R 둠
|
||||
const z = (s) => align.controlElevationAt(build(savedEdits), s);
|
||||
const vertices = [
|
||||
{ chainage_m: 40, elevation_m: z(40) },
|
||||
{ chainage_m: 120, elevation_m: z(120) + 1 },
|
||||
{ chainage_m: 200, elevation_m: z(200) },
|
||||
];
|
||||
const e7 = keep("freeAuto", tidy(free.buildFreeEdits(base, savedEdits, vertices, saved)));
|
||||
out.freeAuto = Object.keys(e7.curve_radii);
|
||||
out.freeUser = Object.keys(tidy(free.buildFreeEdits(base, userEdits, vertices, saved)).curve_radii);
|
||||
|
||||
// ⑧ 자유지정 range 단계 실시간 띠 · Esc = 시작만 취소
|
||||
{
|
||||
const stations = S;
|
||||
let edits = align.emptyEdits();
|
||||
const log = { closed: 0 };
|
||||
const draw = free.createFreeDraw({
|
||||
base: () => base,
|
||||
alignment: () => build(edits),
|
||||
edits: () => edits,
|
||||
apply: (next) => (edits = next),
|
||||
close: () => (log.closed += 1),
|
||||
changed: () => undefined,
|
||||
});
|
||||
draw.click(41, 0, stations);
|
||||
draw.hover({ chainageM: 117, elevationM: 0, stations });
|
||||
out.liveBand = draw.marks().band;
|
||||
out.liveEdges = draw.marks().edges;
|
||||
draw.hover({ chainageM: 13, elevationM: 0, stations });
|
||||
out.liveReverse = draw.marks().band;
|
||||
draw.hover(null);
|
||||
out.outside = { band: draw.marks().band, edges: draw.marks().edges };
|
||||
out.hintPending = draw.hint();
|
||||
out.escTaken = draw.key("Escape");
|
||||
out.afterEsc = { phase: draw.phase(), edges: draw.marks().edges, closed: log.closed };
|
||||
draw.click(41, 0, stations);
|
||||
draw.click(158, 0, stations);
|
||||
draw.hover({ chainageM: 260, elevationM: 0, stations });
|
||||
out.fixedBand = draw.marks().band;
|
||||
out.armedPhase = draw.phase();
|
||||
out.escArmed = draw.key("Escape");
|
||||
out.afterEscArmed = { phase: draw.phase(), closed: log.closed };
|
||||
}
|
||||
|
||||
// ⑩ 범위 띠 그림 — 가짜 DOM
|
||||
class El {
|
||||
constructor(tag) {
|
||||
this.tag = tag;
|
||||
this.attrs = {};
|
||||
this.children = [];
|
||||
this.parent = null;
|
||||
this.textContent = "";
|
||||
this.listeners = {};
|
||||
this.isConnected = true;
|
||||
this.classList = { toggle: () => undefined, add: () => undefined };
|
||||
}
|
||||
setAttribute(name, value) {
|
||||
this.attrs[name] = value;
|
||||
}
|
||||
append(...items) {
|
||||
items.forEach((item) => {
|
||||
item.parent = this;
|
||||
this.children.push(item);
|
||||
});
|
||||
}
|
||||
remove() {
|
||||
if (!this.parent) return;
|
||||
this.parent.children = this.parent.children.filter((c) => c !== this);
|
||||
this.parent = null;
|
||||
}
|
||||
addEventListener(type, fn, opts) {
|
||||
(this.listeners[type] ??= []).push(fn);
|
||||
opts?.signal?.addEventListener("abort", () => {
|
||||
this.listeners[type] = this.listeners[type].filter((f) => f !== fn);
|
||||
});
|
||||
}
|
||||
fire(type, event = {}) {
|
||||
(this.listeners[type] ?? []).forEach((fn) => fn(event));
|
||||
}
|
||||
getBoundingClientRect() {
|
||||
return { left: 0, top: 0 };
|
||||
}
|
||||
}
|
||||
global.document = {
|
||||
createElementNS: (_ns, tag) => new El(tag),
|
||||
createElement: (tag) => new El(tag),
|
||||
};
|
||||
const shape = (svg) =>
|
||||
svg.children.map((c) =>
|
||||
c.tag === "text"
|
||||
? `text:${c.textContent}`
|
||||
: c.tag === "rect"
|
||||
? `rect:${c.attrs.x}+${c.attrs.width}`
|
||||
: `line:${c.attrs.x1}`,
|
||||
);
|
||||
{
|
||||
const x = (m) => m * 2;
|
||||
const svg = new El("svg");
|
||||
band.appendRangeBand(svg, x, 100, 120, 40);
|
||||
out.bandShape = shape(svg);
|
||||
const one = new El("svg");
|
||||
band.appendRangeBand(one, x, 100, 40, null);
|
||||
out.bandStart = shape(one);
|
||||
|
||||
const chart = new El("div");
|
||||
const scroller = new El("div");
|
||||
const geom = {
|
||||
chart,
|
||||
scroller,
|
||||
x,
|
||||
toChainage: (px) => px / 2,
|
||||
widthPx: 640,
|
||||
heightPx: 100,
|
||||
stations: S,
|
||||
};
|
||||
let start = null;
|
||||
const view = band.createRangeBandView((hover) => (start === null ? null : [start, hover]));
|
||||
view.mount(geom, true);
|
||||
out.viewIdle = chart.children.length;
|
||||
start = 40;
|
||||
view.redraw();
|
||||
out.viewStart = shape(chart.children[0]);
|
||||
chart.fire("mousemove", { clientX: 2 * 117 });
|
||||
out.viewHover = shape(chart.children[0]);
|
||||
chart.fire("mouseleave");
|
||||
out.viewLeave = shape(chart.children[0]);
|
||||
chart.fire("mousemove", { clientX: 2 * 61 });
|
||||
view.mount(geom, true); // 다시 그림(줌 · 스크롤 · 계획선 바뀜) 뒤에도 커서 자리 이어짐
|
||||
out.viewRemount = shape(chart.children[0]);
|
||||
view.mount(geom, false);
|
||||
out.viewOff = chart.children.length;
|
||||
}
|
||||
|
||||
// ⑨ 도구 상태 — [직선화] 부분 구간은 붉은 강조(selectedRuns) 아님 · Esc 취소 · 모드 유지
|
||||
{
|
||||
const picked = [];
|
||||
const t = tools.createProfileTools({
|
||||
onStraighten: (from, to) => picked.push([from, to]),
|
||||
onShift: () => undefined,
|
||||
onTilt: (run, delta) => picked.push(["tilt", run.fromM, run.toM, delta]),
|
||||
onResetToSaved: () => undefined,
|
||||
canResetToSaved: () => false,
|
||||
onUndo: () => undefined,
|
||||
onRedo: () => undefined,
|
||||
canUndo: () => false,
|
||||
canRedo: () => false,
|
||||
hasStraightRuns: () => true,
|
||||
onChanged: () => undefined,
|
||||
onModeChange: () => undefined,
|
||||
freeHint: () => "",
|
||||
mountOverlay: () => undefined,
|
||||
});
|
||||
const press = (label) => {
|
||||
const button = t.render().children.find((c) => c.textContent === label);
|
||||
button.fire("click", { stopPropagation: () => undefined });
|
||||
};
|
||||
const hintOf = () => t.render().children.find((c) => c.tag === "em")?.textContent ?? null;
|
||||
out.toolsNone = t.handleStationPick(40);
|
||||
press("직선화");
|
||||
t.handleStationPick(40);
|
||||
out.pending = { at: t.pendingStation(), hint: hintOf() };
|
||||
out.cancel = { taken: t.cancelPick(), pending: t.pendingStation(), mode: t.mode() };
|
||||
out.cancelAgain = t.cancelPick();
|
||||
t.handleRunPick({ fromM: 80, toM: 160, nodes: [80, 100, 120, 140, 160] });
|
||||
out.subPicked = { sub: t.subRun(), red: t.selectedRuns(), hint: hintOf() };
|
||||
press("▲");
|
||||
out.tiltCall = picked.at(-1);
|
||||
t.handleStationPick(60); // 새 시작 = 부분 구간 풂
|
||||
out.newStart = { pending: t.pendingStation(), sub: t.subRun() };
|
||||
t.handleStationPick(180);
|
||||
out.straightenCall = picked.at(-1);
|
||||
press("직선화"); // 끔
|
||||
press("쉬프트");
|
||||
t.handleRunPick({ fromM: 40, toM: 200, nodes: [40, 200] });
|
||||
out.shiftRed = { red: t.selectedRuns().length, sub: t.subRun(), cancel: t.cancelPick() };
|
||||
}
|
||||
|
||||
out.mirror = { base, cases: mirror };
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""271-1~3 B05 종단 [직선화] 측점 고름 · 끝 R 기본 L · [직선화] · [자유지정] 실시간 띠.
|
||||
|
||||
요구 271 · PLAN 461.
|
||||
|
||||
계산(`logic/B05_Profile_UI_Profile_Straighten.ts` · `_Tools.ts` · `design_profile_free.ts` ·
|
||||
`design_profile_range_band.ts` · `_CurveLaw.ts`)은 Node 헬퍼(`helper_271_straighten_pick.cjs`)가
|
||||
트랜스파일해 돌리고, 파이썬 짝(`build_alignment` · `vertical_curve_shortfalls`)은 같은 base · 편집으로
|
||||
곡선 자리 · R · L · 미달 경고가 같은지 거울로 본다.
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_271_straighten_pick.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||
AlignmentPolicy,
|
||||
build_alignment,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import ( # noqa: E402
|
||||
SHORTFALL_TOLERANCE_M,
|
||||
vertical_curve_shortfalls,
|
||||
)
|
||||
|
||||
|
||||
def _read(relative: str) -> str:
|
||||
return (ROOT / relative).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_271_straighten_pick.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _at(curves, chainage):
|
||||
return next((c for c in curves if abs(c["at"] - chainage) < 1e-6), None)
|
||||
|
||||
|
||||
def test_직선화_끝_기본_L_사이_곡선_없음(out):
|
||||
curves = out["straight"]["curves"]
|
||||
assert not [c for c in curves if 40 < c["at"] < 200]
|
||||
assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20)
|
||||
assert out["straight"]["radii"] == []
|
||||
|
||||
|
||||
def test_이미_직선_범위_부분_구간_반대_방향_같음(out):
|
||||
assert out["sub"] == {"fromM": 80, "toM": 160, "nodes": [80, 100, 120, 140, 160]}
|
||||
assert out["subReverse"] == out["sub"]
|
||||
assert out["subBent"] is None and out["subMixed"] is None
|
||||
assert out["subOneSpan"] is None # 사이 꼭짓점 없음 = 꺾을 곳 없음
|
||||
|
||||
|
||||
def test_부분_구간_꺾기_양_끝_L20_거짓_미달_없음(out):
|
||||
curves = out["tiltSub"]["curves"]
|
||||
for at in (80, 120, 160):
|
||||
assert _at(curves, at)["l"] == pytest.approx(20, abs=1e-3)
|
||||
assert not [c for c in curves if 80 < c["at"] < 160 and c["at"] != 120]
|
||||
assert out["tiltSub"]["shortfalls"] == []
|
||||
|
||||
|
||||
def test_법정_판정_오차_짝(out):
|
||||
assert out["tolerance"] == SHORTFALL_TOLERANCE_M == 1e-3
|
||||
assert out["nearMiss"] == []
|
||||
assert len(out["realMiss"]) == 1
|
||||
near = [{"chainage_m": 120, "l_m": 19.99998, "r_m": 800}]
|
||||
assert vertical_curve_shortfalls(near, 100, 20) == []
|
||||
real = [{"chainage_m": 120, "l_m": 19.9, "r_m": 800}]
|
||||
assert vertical_curve_shortfalls(real, 100, 20) == out["realMiss"]
|
||||
|
||||
|
||||
def test_끝_저장_자동_R_지움_사용자_R_둠(out):
|
||||
assert out["autoEnds"]["radii"] == []
|
||||
curves = out["autoEnds"]["curves"]
|
||||
assert _at(curves, 40)["l"] == pytest.approx(20) and _at(curves, 200)["l"] == pytest.approx(20)
|
||||
assert out["userEnd"]["radii"] == {"40.000": 600}
|
||||
assert out["noAuto"] == ["40.000", "200.000"]
|
||||
|
||||
|
||||
def test_BP_EP_곡선_없음(out):
|
||||
assert out["bpEp"] == []
|
||||
assert out["tiltBp"][:2] == [40, 80] # 시점 0 엔 곡선 없음 · 가운데 · 끝만
|
||||
|
||||
|
||||
def test_곡선_섞인_범위_안쪽_R_지움_양_끝_L20(out):
|
||||
assert out["mixed"]["radii"] == []
|
||||
curves = out["mixed"]["curves"]
|
||||
assert not [c for c in curves if 100 < c["at"] < 260]
|
||||
assert _at(curves, 100)["l"] == pytest.approx(20) and _at(curves, 260)["l"] == pytest.approx(20)
|
||||
|
||||
|
||||
def test_자유지정_끝_자동_R_지움_사용자_R_둠(out):
|
||||
assert out["freeAuto"] == ["120.000"]
|
||||
assert out["freeUser"] == ["40.000", "120.000"]
|
||||
|
||||
|
||||
def test_자유지정_범위_실시간_띠_Esc_시작만(out):
|
||||
assert out["liveBand"] == [40, 120] and out["liveEdges"] == [40, 120]
|
||||
assert out["liveReverse"] == [20, 40]
|
||||
assert out["outside"] == {"band": None, "edges": [40]} # 그래프 밖 = 시작선만
|
||||
assert "Esc 시작 취소" in out["hintPending"]
|
||||
assert out["escTaken"] and out["afterEsc"] == {"phase": "range", "edges": [], "closed": 0}
|
||||
assert out["fixedBand"] == [40, 160] and out["armedPhase"] == "armed" # 둘째 클릭 뒤 고정
|
||||
assert out["escArmed"] and out["afterEscArmed"] == {"phase": "range", "closed": 1}
|
||||
|
||||
|
||||
def test_범위_띠_그림(out):
|
||||
assert out["bandShape"] == ["rect:80+160", "text:40.0–120.0m · 80.0m", "line:80", "line:240"]
|
||||
assert out["bandStart"] == ["line:80"]
|
||||
assert out["viewIdle"] == 0 and out["viewOff"] == 0
|
||||
assert out["viewStart"] == ["line:80"]
|
||||
assert out["viewHover"] == out["bandShape"]
|
||||
assert out["viewLeave"] == ["line:80"]
|
||||
assert out["viewRemount"] == ["rect:80+40", "text:40.0–60.0m · 20.0m", "line:80", "line:120"]
|
||||
|
||||
|
||||
def test_도구_직선화_부분_구간_붉은_강조_아님(out):
|
||||
assert out["toolsNone"] is False
|
||||
assert out["pending"] == {
|
||||
"at": 40,
|
||||
"hint": "40.0m 선택 — 두 번째 측점을 고르세요 (Esc 시작 취소)",
|
||||
}
|
||||
assert out["cancel"] == {"taken": True, "pending": None, "mode": "straighten"}
|
||||
assert out["cancelAgain"] is False
|
||||
assert out["subPicked"]["red"] == [] and out["subPicked"]["sub"]["fromM"] == 80
|
||||
assert out["subPicked"]["hint"] == "이미 직선 — ▲▼ 로 꺾으면 양 끝에 기본 L 곡선"
|
||||
assert out["tiltCall"] == ["tilt", 80, 160, 0.1]
|
||||
assert out["newStart"] == {"pending": 60, "sub": None}
|
||||
assert out["straightenCall"] == [60, 180]
|
||||
assert out["shiftRed"] == {"red": 1, "sub": None, "cancel": False} # [쉬프트] 붉은 강조 그대로
|
||||
|
||||
|
||||
def test_파이썬_거울_곡선_자리_R_L_미달(out):
|
||||
base = out["mirror"]["base"]
|
||||
policy = AlignmentPolicy.from_dict(base["policy"])
|
||||
for case in out["mirror"]["cases"]:
|
||||
got = build_alignment(
|
||||
base_s=np.array([n["chainage_m"] for n in base["basePvi"]]),
|
||||
base_z=np.array([n["elevation_m"] for n in base["basePvi"]]),
|
||||
chainage=np.array(base["chainage"]),
|
||||
ground=np.array(base["ground"]),
|
||||
stations=base["stations"],
|
||||
policy=policy,
|
||||
edits=case["edits"],
|
||||
)
|
||||
py = [[c["chainage_m"], c["r_m"], c["l_m"]] for c in got["curves"]]
|
||||
ts = case["curves"]
|
||||
assert [c[0] for c in py] == pytest.approx([c[0] for c in ts], abs=1e-6), case["name"]
|
||||
assert [c[1] for c in py] == pytest.approx([c[1] for c in ts], rel=1e-6), case["name"]
|
||||
assert [c[2] for c in py] == pytest.approx([c[2] for c in ts], abs=1e-6), case["name"]
|
||||
assert not [w for w in got["warnings"] if "미달" in w], case["name"]
|
||||
|
||||
|
||||
def test_배선():
|
||||
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "tools.handleStationPick(station ?? chainageM)" in panel
|
||||
assert "straightSubRun(alignment, fromM, toM)" in panel
|
||||
assert "straightenBetween(base, ctx.edits(), fromM, toM, ctx.savedRadii())" in panel
|
||||
assert "autoRadii: ctx.savedRadii" in panel
|
||||
assert 'window.addEventListener("keydown", onEscape, true)' in panel
|
||||
assert "createRangeBandView(" in panel
|
||||
view = _read("A00_Common/design/design_profile_free_view.ts")
|
||||
assert "appendRangeBand(svg, x, heightPx" in view
|
||||
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
|
||||
rule = css[css.index(".b05-profile-freemark__span {") :]
|
||||
rule = rule[: rule.index("}")]
|
||||
assert "--color-primary" in rule and "--color-surface" in rule # 밝음 · 어두움 토큰
|
||||
|
||||
|
||||
def test_줄_수():
|
||||
for relative in (
|
||||
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts",
|
||||
"B05_Profile/B05_Profile_UI_Profile_Tools.ts",
|
||||
"B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts",
|
||||
"A00_Common/design/logic/design_profile_free.ts",
|
||||
"A00_Common/design/design_profile_free_view.ts",
|
||||
"A00_Common/design/design_profile_range_band.ts",
|
||||
):
|
||||
assert len(_read(relative).splitlines()) <= 700
|
||||
Reference in New Issue
Block a user