feat(B05): 종단 [자유지정] — 범위 고름 · 클릭 점 측점선 붙음 · 실시간 점선 · 기울기 빨강 · 꺾인 점 종단곡선 · 계획고 편집 캐시(215-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
/* 215-1 종단 [자유지정] 단위검증 헬퍼 — `design_profile_free.ts` 를 그 자리에서 트랜스파일해
|
||||
* 범위 · 시작 이음 · 세로선 붙음 · 높이 변환 · 미리 보기 · 기울기 판정 · 종단곡선 · 끝 이음 ·
|
||||
* Esc · Backspace · 적용 편집을 Node 로 돌린다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
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 localRequire = (request) => {
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const free = loadTs(path.join(ROOT, "A00_Common", "design", "design_profile_free.ts"));
|
||||
const align = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
|
||||
// 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다.
|
||||
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
|
||||
const ground = stations.map((s, i) => 100 + 0.3 * i + (i % 2 ? 0.4 : 0));
|
||||
const base = {
|
||||
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,
|
||||
},
|
||||
onlyExplicitCurves: true,
|
||||
basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })),
|
||||
chainage: stations,
|
||||
ground,
|
||||
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
|
||||
};
|
||||
|
||||
function harness() {
|
||||
let edits = align.emptyEdits();
|
||||
const log = { applied: [], closed: 0, changed: 0 };
|
||||
const draw = free.createFreeDraw({
|
||||
base: () => base,
|
||||
alignment: () => align.buildAlignment(base, edits),
|
||||
edits: () => edits,
|
||||
apply: (next) => {
|
||||
log.applied.push(next);
|
||||
edits = next;
|
||||
},
|
||||
close: () => (log.closed += 1),
|
||||
changed: () => (log.changed += 1),
|
||||
});
|
||||
return { draw, log, edits: () => edits };
|
||||
}
|
||||
|
||||
const out = {};
|
||||
const z0 = (s) => ground[stations.indexOf(s)];
|
||||
|
||||
// ① 높이 변환 — 세로 창(기준 + 지금 변환) 왕복 · 변환 글 읽기.
|
||||
const view = { center: 100, pxPerM: 10, zero: 150, scale: 1.2, offset: -20 };
|
||||
out.yAt103_2 = free.chartYAtElevation(view, 103.2);
|
||||
out.zBack = free.elevationAtChartY(view, out.yAt103_2);
|
||||
out.parsed = free.parseWindowTransform("translate(0 -20) scale(1 1.2)");
|
||||
out.parsedNone = free.parseWindowTransform(null);
|
||||
|
||||
// ② 세로선 붙음 — 가장 가까운 측점 · 범위 안만.
|
||||
out.snap79 = free.snapStation(stations, 79);
|
||||
out.snapOutside = free.snapStation(stations, 190, [40, 160]);
|
||||
|
||||
// ③ 범위 → 시작 이음 → 점 → 미리 보기 → 끝 이음.
|
||||
{
|
||||
const { draw, log, edits } = harness();
|
||||
draw.click(41, 0, stations); // 범위 첫 세로선(40)
|
||||
out.pendingEdges = draw.marks().edges;
|
||||
draw.click(158, 0, stations); // 범위 끝(160)
|
||||
out.rangePhase = draw.phase();
|
||||
out.band = draw.marks().band;
|
||||
draw.click(100, 105, stations); // 범위 가운데 — 시작 아님
|
||||
out.stillArmed = draw.phase();
|
||||
draw.click(43, 999, stations); // 시작 세로선 근처 — 그 측점 계획고에 이어 시작
|
||||
out.startPhase = draw.phase();
|
||||
out.startDot = draw.marks().dots[0];
|
||||
draw.click(79, 103.456, stations); // 80 에 붙고 높이는 클릭 높이(0.01 단위)
|
||||
out.point1 = draw.marks().dots[1];
|
||||
draw.click(61, 90, stations); // 뒤로 가는 클릭 — 무시
|
||||
out.dotsAfterBack = draw.marks().dots.length;
|
||||
draw.hover({ chainageM: 121, elevationM: 101.2, stations });
|
||||
out.preview = draw.marks().segments.map((s) => ({
|
||||
to: s.to,
|
||||
grade: Number(s.grade_pct.toFixed(4)),
|
||||
over: s.over,
|
||||
preview: s.preview,
|
||||
}));
|
||||
draw.hover({ chainageM: 121, elevationM: 80, stations }); // 기울기 넘음
|
||||
const steep = draw.marks().segments.at(-1);
|
||||
out.steep = { grade: Number(steep.grade_pct.toFixed(2)), over: steep.over };
|
||||
draw.click(121, 101.2, stations);
|
||||
draw.hover({ chainageM: 157, elevationM: 50, stations }); // 끝 세로선 → 그 계획고로 이음
|
||||
out.finishPreview = draw.marks().segments.at(-1).to;
|
||||
out.hint = draw.hint();
|
||||
draw.click(157, 50, stations); // 끝 세로선 근처 = 끝
|
||||
out.appliedCount = log.applied.length;
|
||||
out.closed = log.closed;
|
||||
out.afterPhase = draw.phase();
|
||||
const next = edits();
|
||||
out.offsetKeys = Object.keys(next.station_offsets).sort();
|
||||
out.radiiKeys = Object.keys(next.curve_radii).sort();
|
||||
const built = align.buildAlignment(base, next);
|
||||
const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
|
||||
out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) };
|
||||
out.ground = { 40: z0(40), 160: z0(160) };
|
||||
const g1 = (103.46 - z0(40)) / 40;
|
||||
const g2 = (101.2 - 103.46) / 40;
|
||||
out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6));
|
||||
out.r80 = next.curve_radii["80.000"];
|
||||
out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null;
|
||||
out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
|
||||
}
|
||||
|
||||
// ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작.
|
||||
{
|
||||
const { draw, log, edits } = harness();
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(160, 0, stations);
|
||||
draw.click(162, 0, stations); // 끝 쪽 세로선에서 시작
|
||||
out.reverseAnchor = draw.marks().dots[0].chainage_m;
|
||||
draw.click(100, 104, stations);
|
||||
out.enter = draw.key("Enter");
|
||||
out.enterApplied = log.applied.length;
|
||||
const built = align.buildAlignment(base, edits());
|
||||
const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
|
||||
out.enterPvi = { 40: pvi(40), 60: pvi(60), 100: pvi(100), 140: pvi(140), 160: pvi(160) };
|
||||
}
|
||||
|
||||
// ⑤ Esc = 취소(편집 없음) · Backspace = 마지막 점 빼기.
|
||||
{
|
||||
const { draw, log } = harness();
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(160, 0, stations);
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(80, 102, stations);
|
||||
draw.click(120, 103, stations);
|
||||
draw.key("Backspace");
|
||||
out.afterBackspace = draw.marks().dots.map((d) => d.chainage_m);
|
||||
draw.key("Backspace");
|
||||
draw.key("Backspace");
|
||||
out.backspaceToArmed = draw.phase();
|
||||
draw.click(40, 0, stations);
|
||||
draw.click(80, 102, stations);
|
||||
out.escape = draw.key("Escape");
|
||||
out.escApplied = log.applied.length;
|
||||
out.escClosed = log.closed;
|
||||
out.escPhase = draw.phase();
|
||||
out.escDots = draw.marks().dots.length;
|
||||
out.enterOutsideDrawing = draw.key("Enter");
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,152 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""215-1 B05 종단 [자유지정] — 범위 고르고 클릭으로 계획선 잇기.
|
||||
|
||||
계산 · 상태(`A00_Common/design/design_profile_free.ts`)는 Node 헬퍼
|
||||
(`helper_215_1_free_profile.cjs`)가 트랜스파일해 돌리고, 화면 배선(도구줄 단추 · 오버레이 ·
|
||||
키 · 적용 길)은 소스로 본다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
|
||||
|
||||
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_215_1_free_profile.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_높이_변환_세로_창_왕복(out):
|
||||
assert out["yAt103_2"] == pytest.approx(121.6)
|
||||
assert out["zBack"] == pytest.approx(103.2)
|
||||
assert out["parsed"] == {"scale": 1.2, "offset": -20}
|
||||
assert out["parsedNone"] == {"scale": 1, "offset": 0}
|
||||
|
||||
|
||||
def test_세로선_붙음_범위_안만(out):
|
||||
assert out["snap79"] == 80
|
||||
assert out["snapOutside"] == 160
|
||||
|
||||
|
||||
def test_범위_고르기_강조(out):
|
||||
assert out["pendingEdges"] == [40]
|
||||
assert out["rangePhase"] == "armed"
|
||||
assert out["band"] == [40, 160]
|
||||
assert out["stillArmed"] == "armed" # 가운데 클릭은 시작이 아님
|
||||
|
||||
|
||||
def test_시작_이음_지금_계획고(out):
|
||||
assert out["startPhase"] == "drawing"
|
||||
assert out["startDot"] == {"chainage_m": 40, "elevation_m": pytest.approx(100.6)}
|
||||
|
||||
|
||||
def test_클릭_점_세로선_붙음_높이(out):
|
||||
assert out["point1"] == {"chainage_m": 80, "elevation_m": pytest.approx(103.46)}
|
||||
assert out["dotsAfterBack"] == 2 # 뒤로 가는 클릭은 무시
|
||||
|
||||
|
||||
def test_점선_미리_보기_기울기_글(out):
|
||||
first, last = out["preview"]
|
||||
assert (first["preview"], last["preview"]) == (False, True)
|
||||
assert last["to"] == {"chainage_m": 120, "elevation_m": pytest.approx(101.2)}
|
||||
assert first["grade"] == pytest.approx(7.15)
|
||||
assert last["grade"] == pytest.approx(-5.65)
|
||||
assert not first["over"] and not last["over"]
|
||||
|
||||
|
||||
def test_기준_넘음_빨강(out):
|
||||
assert out["steep"]["over"] is True
|
||||
assert abs(out["steep"]["grade"]) > 14
|
||||
|
||||
|
||||
def test_끝_이음과_적용_편집(out):
|
||||
assert out["finishPreview"] == {"chainage_m": 160, "elevation_m": pytest.approx(102.4)}
|
||||
assert out["appliedCount"] == 1 and out["closed"] == 1 and out["afterPhase"] == "range"
|
||||
assert out["offsetKeys"] == ["100.000", "120.000", "140.000", "60.000", "80.000"]
|
||||
pvi = out["pvi"]
|
||||
assert pvi["40"] == pytest.approx(out["ground"]["40"]) # 양 끝 그대로
|
||||
assert pvi["160"] == pytest.approx(out["ground"]["160"])
|
||||
assert pvi["80"] == pytest.approx(103.46)
|
||||
assert pvi["120"] == pytest.approx(101.2)
|
||||
assert pvi["60"] == pytest.approx((100.6 + 103.46) / 2) # 점 사이 = 직선
|
||||
assert pvi["140"] == pytest.approx((101.2 + 102.4) / 2)
|
||||
assert out["outsideUntouched"] is True
|
||||
|
||||
|
||||
def test_종단곡선_자동(out):
|
||||
assert out["radiiKeys"] == ["120.000", "80.000"]
|
||||
assert out["r80"] == pytest.approx(out["expectR80"])
|
||||
assert out["curve80L"] == pytest.approx(20) # 정책 기본 길이 L
|
||||
|
||||
|
||||
def test_Enter_끝_거꾸로_시작(out):
|
||||
assert out["reverseAnchor"] == 160
|
||||
assert out["enter"] is True and out["enterApplied"] == 1
|
||||
pvi = out["enterPvi"]
|
||||
assert pvi["40"] == pytest.approx(100.6) and pvi["160"] == pytest.approx(102.4)
|
||||
assert pvi["100"] == pytest.approx(104)
|
||||
assert pvi["60"] == pytest.approx(100.6 + (104 - 100.6) / 3, abs=1e-5)
|
||||
|
||||
|
||||
def test_Backspace_와_Esc_취소(out):
|
||||
assert out["afterBackspace"] == [40, 80]
|
||||
assert out["backspaceToArmed"] == "armed"
|
||||
assert out["escape"] is True
|
||||
assert out["escApplied"] == 0 and out["escClosed"] == 1
|
||||
assert out["escPhase"] == "range" and out["escDots"] == 0
|
||||
assert out["enterOutsideDrawing"] is False
|
||||
|
||||
|
||||
def test_도구줄_직선화_오른쪽_자유지정():
|
||||
tools = _read("B05_Profile/B05_Profile_UI_Profile_Tools.ts")
|
||||
assert '"자유지정"' in tools
|
||||
assert "wrap.append(reset, undo, redo, straighten, free, shift, up, down)" in tools
|
||||
assert 'className = "b05-route-profile__tool"' in tools # 같은 단추 양식
|
||||
|
||||
|
||||
def test_적용은_직선화와_같은_길_키_배선():
|
||||
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "apply: (next) => ctx.applyEdits(next)" in panel
|
||||
assert 'tools.mode() === "free" && free.key(key)' in panel
|
||||
profile = _read("A00_Common/design/design_profile.ts")
|
||||
# applyEdits = 캐시(store.replace) + 되돌리기 기록 — 자동저장 없음
|
||||
assert "store.replace(next);\n history.record();" in profile
|
||||
render = _read("B05_Profile/B05_Profile_UI_Profile_Render.ts")
|
||||
assert "ctx.tools.mountOverlay(" in render
|
||||
|
||||
|
||||
def test_오버레이_점선_빨강_글():
|
||||
view = _read("A00_Common/design/design_profile_free_view.ts")
|
||||
assert "is-preview" in view and "is-over" in view
|
||||
assert "grade_pct.toFixed(1)}%" in view
|
||||
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
|
||||
assert ".b05-profile-freemark__line {" in css and "stroke-dasharray: 6 4" in css
|
||||
assert ".b05-profile-freemark__line.is-over" in css
|
||||
|
||||
|
||||
def test_줄_수_늘리지_않음():
|
||||
for relative in (
|
||||
"A00_Common/design/design_profile.ts",
|
||||
"A00_Common/design/design_profile_free.ts",
|
||||
"A00_Common/design/design_profile_free_view.ts",
|
||||
):
|
||||
assert len(_read(relative).splitlines()) <= 700
|
||||
assert len(_read("A00_Common/design/design_profile.ts").splitlines()) <= 698
|
||||
Reference in New Issue
Block a user