- 까닭: 노선이 바뀌면 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
251 lines
9.2 KiB
JavaScript
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);
|
|
});
|