Files
Aislo/resources/tester/helper_356_center.cjs
T
eomsangdonandClaude Opus 5.5 842473bcea fix(B06): 364-7 중심점 좌우 = 노면 그대로 — 바뀐 구간 첫 계획선 맞춤에 쓸린 모든 측점 계획고 · 꺾임 · R 을 옮기기 전 종단으로 되살림(holdGrade · 한 측점 보정 대신)
- 까닭: 노선이 바뀌면 planline 단계가 바뀐 구간을 새 지반 첫 계획선으로 다시 맞춤(B05 꺾임점 끌기와 같은 길) · 옛 보정은 누른 측점 하나만 되돌림
- 고침: 옛 · 새 꺾은선 꼭짓점 · 측점을 견줘 다른 곳만 계획고 편집(바뀐 구간 끝까지 측점 열쇠 그대로 · 뒤는 길이 차) · 옛 R 되살림 · B05 꺾임점 끌기 규칙은 그대로
- 시험: 356 묶음에 바뀐 구간 안 사용자 계획고 · R 넣고 모든 측점 ±0.001 · 최대 기울기 · R 그대로 · 356-4 헬퍼 새 함수로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-17 00:32:55 +09:00

251 lines
9.2 KiB
JavaScript

/* 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);
});