From 182de3b2e3987b3266ad428f8532c33a558e5766 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 22:49:09 +0900 Subject: [PATCH] =?UTF-8?q?fix(461=20B05=20=EC=A2=85=EB=8B=A8=20=EC=A7=81?= =?UTF-8?q?=EC=84=A0=ED=99=94):=20=ED=81=B4=EB=A6=AD=20=3D=20=EB=8A=98=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EA=B3=A0=EB=A6=84=20=C2=B7=20=EB=81=9D=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20R=20=EC=A7=80=EC=9B=80=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20L=20=C2=B7=20=EC=9D=B4=EB=AF=B8=20=EC=A7=81?= =?UTF-8?q?=EC=84=A0=20=EB=B2=94=EC=9C=84=20=EB=B6=80=EB=B6=84=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=C2=B7=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EB=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 직선화 첫 클릭이 직선 안이면 직선 통째 고름(붉은 강조)으로 새던 갈림 지움 · 빈 곳 클릭 측점선에 붙음 - 이미 직선인 범위 = 부분 구간 고름(보라 띠 · 안내) · ▲▼ = 가운데 + 양 끝 기본 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 --- A00_Common/design/design_profile_free_view.ts | 26 +- .../design/design_profile_range_band.ts | 149 +++++++++ .../design/logic/design_profile_free.ts | 38 ++- .../B05_Profile_Engine_Grade_CurveLaw.py | 7 +- .../B05_Profile_UI_Profile_Panel_Tools.ts | 70 +++- B05_Profile/B05_Profile_UI_Profile_Tools.ts | 30 +- B05_Profile/B05_Profile_UI_Style_Table.css | 9 + .../logic/B05_Profile_Profile_CurveLaw.ts | 7 +- .../B05_Profile_UI_Profile_Straighten.ts | 40 ++- .../tester/helper_271_straighten_pick.cjs | 306 ++++++++++++++++++ resources/tester/test_271_straighten_pick.py | 196 +++++++++++ 11 files changed, 825 insertions(+), 53 deletions(-) create mode 100644 A00_Common/design/design_profile_range_band.ts create mode 100644 resources/tester/helper_271_straighten_pick.cjs create mode 100644 resources/tester/test_271_straighten_pick.py diff --git a/A00_Common/design/design_profile_free_view.ts b/A00_Common/design/design_profile_free_view.ts index 48584c4ee..7c9d1adf7 100644 --- a/A00_Common/design/design_profile_free_view.ts +++ b/A00_Common/design/design_profile_free_view.ts @@ -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); diff --git a/A00_Common/design/design_profile_range_band.ts b/A00_Common/design/design_profile_range_band.ts new file mode 100644 index 000000000..a9b686a1e --- /dev/null +++ b/A00_Common/design/design_profile_range_band.ts @@ -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): 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 }; +} diff --git a/A00_Common/design/logic/design_profile_free.ts b/A00_Common/design/logic/design_profile_free.ts index 77d0ae4a9..a19cd6a39 100644 --- a/A00_Common/design/logic/design_profile_free.ts +++ b/A00_Common/design/logic/design_profile_free.ts @@ -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> = {}, ): 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>; /** 모드를 끈다(적용 뒤 · 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 취소)"; diff --git a/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py b/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py index e9d454ffd..0090a970f 100644 --- a/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py +++ b/B05_Profile/B05_Profile_Engine_Grade_CurveLaw.py @@ -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 미달" diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts index 54e24ff6d..7f57e4b54 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts @@ -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 }; } diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts index b82790a57..bf76034b2 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts @@ -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"); diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 71a35fa80..6860b155a 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -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); diff --git a/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts b/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts index c09e28a0e..cb55e1265 100644 --- a/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts +++ b/B05_Profile/logic/B05_Profile_Profile_CurveLaw.ts @@ -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 — ` + diff --git a/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts b/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts index 843024404..14500970c 100644 --- a/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts +++ b/B05_Profile/logic/B05_Profile_UI_Profile_Straighten.ts @@ -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, + ends: number[], + autoRadii: Readonly>, +): 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> = {}, ): 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); diff --git a/resources/tester/helper_271_straighten_pick.cjs b/resources/tester/helper_271_straighten_pick.cjs new file mode 100644 index 000000000..f2e0572a5 --- /dev/null +++ b/resources/tester/helper_271_straighten_pick.cjs @@ -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)); diff --git a/resources/tester/test_271_straighten_pick.py b/resources/tester/test_271_straighten_pick.py new file mode 100644 index 000000000..fc75e60e5 --- /dev/null +++ b/resources/tester/test_271_straighten_pick.py @@ -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