/* 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 stationsTo = (end) => [0, 20, 40, 60, 80, end]; // 끝 측점 = 노선 길이 const baseOf = (pvi, end = 100) => ({ policy, onlyExplicitCurves: true, basePvi: pvi.map(([chainage_m, elevation_m]) => ({ chainage_m, elevation_m })), chainage: stationsTo(end), ground: stationsTo(end).map(() => 100), stations: stationsTo(end).map((s) => ({ station_id: `S${s}`, chainage_m: s })), }); const lengthOf = (line) => line.slice(1).reduce((sum, [x, y], i) => sum + Math.hypot(x - line[i][0], y - line[i][1]), 0); 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(); // 바뀐 구간 안 사용자 계획고 · R(364-7) — 묶음이 버려도 노면 그대로 되살아나야 함 const state = { line: straight.map((p) => [...p]), base: savedBase, edits: { station_offsets: { "20.000": 1 }, curve_radii: { "20.000": 200 } }, }; const heights = () => pipe.output("profile").stations.map((row) => row.plan_elevation_m); const maxGrade = () => Math.max(...pipe.output("profile").segments.map((g) => Math.abs(g.grade_percent))); 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) : name === "route" ? { line: state.line } : null, input: (key) => (key === "alignment" ? state.edits : null), route(planned) { const end = lengthOf(planned); state.line = planned.map((p) => [...p]); state.base = baseOf( [...refitBase.basePvi.slice(0, -1).map((n) => [n.chainage_m, n.elevation_m]), [end, 110]], end, ); 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); out.heightsBefore = heights(); out.gradeBefore = maxGrade(); out.curvesBefore = pipe.output("profile").curves.map((c) => [c.chainage_m, c.r_m]); 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.heightsAfter = heights(); out.gradeAfter = maxGrade(); out.curvesAfter = pipe.output("profile").curves.map((c) => [c.chainage_m, c.r_m]); 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); });