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:
2026-10-16 20:53:10 +09:00
co-authored by Claude Opus 5.5
parent 55c5aab90e
commit c67a39c266
10 changed files with 733 additions and 27 deletions
+227
View File
@@ -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