feat(B06): 356-3 횡단 중심점 고름 — ▲▼ 계획고 · ◀▶ · 가로 끌기 = 그 측점 노선 좌우
- 중심점 = 보라 선 고름 끝 조각(판정 원 · 보라 선 위 · 다른 고름과 하나만) · 십자는 누름 통과 - 좌우 = 노선 편집기가 등록한 host(B05_Profile_UI_RouteEdit_Shift) — 0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김 - 끄는 동안 · 키를 잇는 동안 십자만 옮겨 보임 · 놓을 때 한 번 커밋 - 뒤 측점은 계산 묶음이 조용히 · 그 측점 계획고 = 옮기기 전 값(계획고 편집) · 노선 + 계획고 = 되돌리기 한 걸음 - B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움(design_page 등록) - 시험 test_356 · helper_356(노드 고름 · 뒤 측점 범위 · 높이 그대로 · 되돌리기 한 걸음 · 바로 열기) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
/* PLAN 356-3 단위검증 헬퍼 — 횡단 중심점 좌우 = 그 측점 노선 좌우.
|
||||
* 진짜 TS(`logic/B05_Profile_RouteShift` · `B05_Profile_UI_RouteEdit_Shift` · `B06_Section_UI_Cross_Center` ·
|
||||
* 페이지 사진첩 `B05_Profile_UI_Profile_History`)를 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
|
||||
* 계산 묶음은 흉내(노선이 바뀌면 바뀐 구간 첫 계획선을 새로 맞춤 = 그 측점 계획고가 바뀜) · 짝 = `test_356_b06_card_center.py`.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
const session = new Map();
|
||||
globalThis.sessionStorage = {
|
||||
get length() {
|
||||
return session.size;
|
||||
},
|
||||
key: (i) => [...session.keys()][i] ?? null,
|
||||
getItem: (k) => session.get(k) ?? null,
|
||||
setItem: (k, v) => session.set(k, String(v)),
|
||||
removeItem: (k) => session.delete(k),
|
||||
};
|
||||
|
||||
const toasts = [];
|
||||
let pipe = null;
|
||||
const graded = [];
|
||||
const STUBS = {
|
||||
"../A00_Common/design/design_pipeline_page": {
|
||||
pagePipeline: () => pipe,
|
||||
pipelineGrade: (_id, edits) => (graded.push(edits), pipe.grade(edits)),
|
||||
},
|
||||
};
|
||||
|
||||
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) =>
|
||||
(CATALOG.isCatalogRequest(request) ? CATALOG : null) ??
|
||||
STUBS[request] ??
|
||||
(request.startsWith("@util/")
|
||||
? loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`))
|
||||
: request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request));
|
||||
new Function("exports", "module", "require", js)(box.exports, box, near);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const B05 = path.join(ROOT, "B05_Profile");
|
||||
const shift = loadTs(path.join(B05, "logic", "B05_Profile_RouteShift.ts"));
|
||||
const align = loadTs(path.join(B05, "logic", "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
|
||||
const grade = loadTs(path.join(B05, "logic", "B05_Profile_Pipeline_Grade.ts"));
|
||||
const history = loadTs(path.join(B05, "B05_Profile_UI_Profile_History.ts"));
|
||||
const editShift = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Shift.ts"));
|
||||
const center = loadTs(path.join(ROOT, "B06_Section", "B06_Section_UI_Cross_Center.ts"));
|
||||
|
||||
const out = {};
|
||||
|
||||
// ── 노드 고름 — 직선 끼움 · 0.5 m 안 노드 옮김 · 고정 · 곡선 안 = 교각점 ──
|
||||
const straight = [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
];
|
||||
const plan = (planned, line, curves, locked, chainageM, deltaM) =>
|
||||
shift.planRouteShift({ planned, line, curves, locked, chainageM, deltaM });
|
||||
out.insert = plan(straight, straight, [], [true, true], 40, 1);
|
||||
const kinked = [
|
||||
[0, 0],
|
||||
[40.3, 0],
|
||||
[100, 0],
|
||||
];
|
||||
out.near = plan(kinked, kinked, [], [true, false, true], 40, -0.5);
|
||||
out.nearLocked = plan(kinked, kinked, [], [true, true, true], 40, -0.5);
|
||||
const bent = [
|
||||
[0, 0],
|
||||
[60, 0],
|
||||
[60, 60],
|
||||
];
|
||||
const built = curve.buildEditedPolyline(bent, [true, true, true], [null, 30, null], 30);
|
||||
const bc = built.curves[0].start;
|
||||
const tangentIn = Math.hypot(bc[0], bc[1]); // 시점 ~ BC(첫 토막 위)
|
||||
out.curveHit = plan(bent, built.vertices, built.curves, [true, false, true], tangentIn + 5, 1);
|
||||
out.curveApex = bent[1];
|
||||
|
||||
// ── 뒤 측점 다시 셈 — 끼운 뒤 바뀐 범위 = 끼운 노드 앞 노드부터 끝까지 ──
|
||||
const after = [straight[0], out.insert.at, straight[1]];
|
||||
out.range = grade.routeRange(straight, after);
|
||||
|
||||
// ── 높이 그대로 · 되돌리기 한 걸음 · B06 바로 열기 — 편집기 host + 흉내 묶음 ──
|
||||
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,
|
||||
};
|
||||
const stations = [0, 20, 40, 60, 80, 100];
|
||||
const baseOf = (pvi) => ({
|
||||
policy,
|
||||
onlyExplicitCurves: true,
|
||||
basePvi: pvi.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m })),
|
||||
chainage: stations,
|
||||
ground: stations.map(() => 100),
|
||||
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
|
||||
});
|
||||
const savedBase = baseOf([
|
||||
[0, 100],
|
||||
[100, 110],
|
||||
]);
|
||||
// 노선이 바뀌면 바뀐 구간 첫 계획선을 새 지반에 다시 맞춤 — 40 m 계획고가 104 → 102 쪽으로 쓸림
|
||||
const refitBase = baseOf([
|
||||
[0, 100],
|
||||
[60, 102.5],
|
||||
[100, 110],
|
||||
]);
|
||||
let generation = 0;
|
||||
const listeners = new Set();
|
||||
const state = { base: savedBase, edits: align.emptyEdits() };
|
||||
const emit = () => {
|
||||
const gen = generation;
|
||||
setTimeout(() => {
|
||||
for (const fn of [...listeners]) fn({ kind: "stage", stage: "profile", generation: gen });
|
||||
}, 5);
|
||||
};
|
||||
pipe = {
|
||||
get generation() {
|
||||
return generation;
|
||||
},
|
||||
subscribe: (fn) => (listeners.add(fn), () => listeners.delete(fn)),
|
||||
output: (name) =>
|
||||
name === "planline"
|
||||
? state.base
|
||||
: name === "profile"
|
||||
? align.buildAlignment(state.base, state.edits)
|
||||
: null,
|
||||
input: (key) => (key === "alignment" ? state.edits : null),
|
||||
route(planned) {
|
||||
state.base = refitBase;
|
||||
state.edits = align.emptyEdits(); // 바뀐 구간 안 편집은 버림
|
||||
sessionStorage.setItem("aislo:draft:route-draft:p", JSON.stringify(planned));
|
||||
sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(state.edits));
|
||||
generation += 1;
|
||||
emit();
|
||||
},
|
||||
grade(edits) {
|
||||
state.edits = edits;
|
||||
sessionStorage.setItem("aislo:draft:alignment-draft:0", JSON.stringify(edits));
|
||||
generation += 1;
|
||||
emit();
|
||||
},
|
||||
};
|
||||
|
||||
const album = history.createProfileHistory(() => undefined);
|
||||
// 편집기 흉내 — 사진(산 배열) · 한 걸음(사진첩에 쌓고 묶음에 노선을 넘김)
|
||||
const editor = {
|
||||
planned: straight.map((p) => [...p]),
|
||||
curveOn: [true, true],
|
||||
curveRadius: [null, null],
|
||||
curveLock: [null, null],
|
||||
curveArc: [null, null],
|
||||
apexLock: [true, true],
|
||||
picked: -1,
|
||||
};
|
||||
const applied = [];
|
||||
const disposers = [];
|
||||
const host = { onDispose: (fn) => disposers.push(fn) };
|
||||
const mountEditor = () =>
|
||||
editShift.bindRouteShift(
|
||||
host,
|
||||
"p",
|
||||
() => editor,
|
||||
(message) => {
|
||||
applied.push(message);
|
||||
album.record(); // 사진첩이 멈춰 있으면 안 쌓임 — 끝에 한 번
|
||||
pipe.route(editor.planned);
|
||||
},
|
||||
() => [editor.planned, []],
|
||||
);
|
||||
|
||||
(async () => {
|
||||
const zBefore = align.planElevationAt(pipe.output("profile"), 40);
|
||||
const sessionBefore = JSON.stringify([...session.entries()]);
|
||||
// B06 바로 열기 — host 없음 → 페이지가 단 길(종단 보기 숨긴 채 세움)이 편집기를 세움
|
||||
let opened = 0;
|
||||
center.setRouteShiftOpener(
|
||||
async () => {
|
||||
opened += 1;
|
||||
setTimeout(mountEditor, 20);
|
||||
},
|
||||
(text, kind) => toasts.push([text, kind]),
|
||||
);
|
||||
out.moved = await center.shiftRouteAt(40, 1);
|
||||
out.opened = opened;
|
||||
out.planned = editor.planned;
|
||||
out.applied = applied;
|
||||
out.graded = graded.length;
|
||||
out.zBefore = zBefore;
|
||||
out.zAfter = align.planElevationAt(pipe.output("profile"), 40);
|
||||
out.zRefit = align.planElevationAt(align.buildAlignment(refitBase, align.emptyEdits()), 40);
|
||||
out.canUndo = album.canUndo();
|
||||
album.undo();
|
||||
out.undoOne = JSON.stringify([...session.entries()]) === sessionBefore;
|
||||
out.canUndoAfter = album.canUndo();
|
||||
// 고정 노드 — 안 옮기고 까닭 알림(되돌리기 안 쌓임)
|
||||
editor.planned = kinked.map((p) => [...p]);
|
||||
editor.apexLock = [true, true, true];
|
||||
const lockedMoved = await center.shiftRouteAt(40, -0.5);
|
||||
out.locked = { moved: lockedMoved, toast: toasts.at(-1), planned: editor.planned };
|
||||
disposers.forEach((fn) => fn());
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})().catch((error) => {
|
||||
process.stderr.write(String(error?.stack ?? error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -2,12 +2,23 @@
|
||||
|
||||
못박는 것
|
||||
- 356-2 카드 초점 없음 — 마우스로 고른 뒤 키를 눌러도 초점 테두리(다른 색) 안 뜸 · ▲▼ 계획고는 창 듣개.
|
||||
- 356-3 중심점 = 보라 선 고름 끝 조각(판정 원 · 노면 판정보다 위 · 다른 고름과 하나만) · ▲▼ = 종단 계획고 ·
|
||||
◀▶ · 가로 끌기 = 그 측점 노선 좌우(0.5 m 안 노드 옮김 · 곡선 안 = 교각점 · 아니면 끼움 · 고정 노드 안 옮김) ·
|
||||
놓을 때 한 번 · 뒤 측점은 묶음이 다시 셈(바뀐 범위 = 앞 노드 ~ 끝) · 그 측점 계획고 그대로 · 되돌리기 한 걸음 ·
|
||||
B06 을 바로 열어 편집기가 없으면 종단 보기를 숨긴 채 세움.
|
||||
|
||||
진짜 TS 는 `helper_356_center.cjs` 가 Node 로 돌림.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B06 = ROOT / "B06_Section"
|
||||
|
||||
@@ -17,3 +28,58 @@ def test_카드_초점_없음():
|
||||
assert "card.tabIndex" not in view
|
||||
assert 'card.addEventListener("keydown"' not in view
|
||||
assert 'card.addEventListener("click", () => onSelect(section.station_id));' in view
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts() -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_356_center.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_노드_고름_끼움_옮김_고정_교각점(ts):
|
||||
assert ts["insert"] == {
|
||||
"kind": "insert",
|
||||
"index": 1,
|
||||
"at": [40, 1],
|
||||
} # 직선 = 측점 자리 + 좌 1 m 끼움
|
||||
assert ts["near"] == {"kind": "move", "index": 1, "to": [40.3, -0.5]} # 0.5 m 안 노드 = 옮김
|
||||
assert ts["nearLocked"] == {"kind": "locked", "index": 1} # 고정 노드 = 안 옮김
|
||||
hit = ts["curveHit"]
|
||||
assert hit["kind"] == "move" and hit["index"] == 1 # 곡선 안 = 그 교각점
|
||||
ax, ay = ts["curveApex"]
|
||||
assert ((hit["to"][0] - ax) ** 2 + (hit["to"][1] - ay) ** 2) ** 0.5 == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_뒤_측점은_묶음이_다시_셈(ts):
|
||||
assert ts["range"] == {"fromM": 0, "toM": None} # 앞 노드 ~ 끝(Infinity)
|
||||
|
||||
|
||||
def test_높이_그대로_되돌리기_한_걸음_바로_열기(ts):
|
||||
assert ts["opened"] == 1 and ts["moved"] is True # 편집기 없음 → 종단 보기 숨긴 채 세움
|
||||
assert ts["planned"] == [[0, 0], [40, 1], [100, 0]]
|
||||
assert len(ts["applied"]) == 1 # 편집기 한 걸음
|
||||
assert ts["zRefit"] != pytest.approx(ts["zBefore"], abs=1e-3) # 묶음 다시 맞춤이면 쓸림
|
||||
assert ts["graded"] == 1 and ts["zAfter"] == pytest.approx(ts["zBefore"], abs=1e-3)
|
||||
assert ts["canUndo"] and ts["undoOne"] and not ts["canUndoAfter"] # 노선 + 계획고 = 한 걸음
|
||||
locked = ts["locked"]
|
||||
assert locked["moved"] is False and locked["toast"][1] == "info"
|
||||
assert locked["planned"] == [[0, 0], [40.3, 0], [100, 0]]
|
||||
|
||||
|
||||
def test_배선():
|
||||
pick = (B06 / "B06_Section_UI_Cross_Design_Pick.ts").read_text(encoding="utf-8")
|
||||
assert "center: section.chainage_m," in pick # designLineMoves → 카드 고름 끝 조각
|
||||
assert 'if (axis === "center") return centerShift?.step(dir);' in pick
|
||||
order = (B06 / "B06_Section_UI_Cross_Pick_Order.ts").read_text(encoding="utf-8")
|
||||
assert "layer.append(...surfaces, ...edges, ...lines, ...centers);" in order
|
||||
edit = (ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
assert "bindRouteShift(host, projectId, snapshotNow, applyEdit," in edit
|
||||
page = (ROOT / "A00_Common" / "design" / "design_page.ts").read_text(encoding="utf-8")
|
||||
assert 'setRouteShiftOpener(() => shared.ensureView("profile"), showToast);' in page
|
||||
|
||||
Reference in New Issue
Block a user