Files
Aislo/resources/tester/helper_356_center.cjs
T
eomsangdonandClaude Opus 5.5 c67a39c266 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
2026-10-16 20:53:10 +09:00

228 lines
8.1 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 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);
});