Files
Aislo/resources/tester/helper_370_2_toe_stop.cjs
T

394 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* PLAN 370-2 단위검증 헬퍼 — B06 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기.
* 화면 TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 결과 = 표준출력 JSON 한 벌.
* 입력 = argv[2] 표준단면 JSON(파이썬 config) · 마루 지반(우측 −4 m 너머 내리막). 짝 = `test_370_2_toe_stop.py`.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const STUBS = {
"@ui/ui_template_toast": { showToast: () => undefined },
"@ui/ui_template_elements": { showToast: () => undefined },
};
class FakeNode {
constructor(tag) {
this.tagName = String(tag);
this.children = [];
this.attrs = {};
this.listeners = {};
this.hidden = false;
this.title = "";
this._text = "";
this._classes = new Set();
const self = this;
this.classList = {
add: (...names) => names.forEach((n) => self._classes.add(n)),
remove: (...names) => names.forEach((n) => self._classes.delete(n)),
toggle: (name, force) => {
const on = force === undefined ? !self._classes.has(name) : !!force;
on ? self._classes.add(name) : self._classes.delete(name);
return on;
},
contains: (name) => self._classes.has(name),
};
this.style = { setProperty: () => {} };
}
set className(value) {
this._classes = new Set(String(value).split(/\s+/).filter(Boolean));
}
get className() {
return [...this._classes].join(" ");
}
set textContent(value) {
this._text = String(value);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent).join("");
}
get childElementCount() {
return this.children.length;
}
get isConnected() {
return true;
}
_detach(kid) {
if (kid.parent) kid.parent.children = kid.parent.children.filter((k) => k !== kid);
kid.parent = this;
}
append(...kids) {
kids.forEach((k) => {
if (typeof k === "string") return;
this._detach(k);
this.children.push(k);
});
}
prepend(...kids) {
kids.reverse().forEach((k) => {
this._detach(k);
this.children.unshift(k);
});
}
appendChild(kid) {
this.append(kid);
return kid;
}
insertBefore(kid, ref) {
this._detach(kid);
const at = ref ? this.children.indexOf(ref) : -1;
if (at < 0) this.children.push(kid);
else this.children.splice(at, 0, kid);
return kid;
}
remove() {
if (this.parent) this.parent.children = this.parent.children.filter((k) => k !== this);
}
querySelector() {
return null;
}
setAttribute(name, value) {
this.attrs[name] = String(value);
if (name === "class") this.className = value;
}
getAttribute(name) {
return name === "class" ? this.className : (this.attrs[name] ?? null);
}
hasAttribute(name) {
return name in this.attrs;
}
addEventListener(type, fn) {
(this.listeners[type] ??= []).push(fn);
}
click() {
const event = { stopPropagation: () => {}, preventDefault: () => {} };
(this.listeners.click ?? []).forEach((fn) => fn(event));
}
}
const docKeys = new Set(); // 방향키 듣개(379-1 · 정확히 0 자리)
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
createElementNS: (_ns, tag) => new FakeNode(tag),
addEventListener: (type, fn) => type === "keydown" && docKeys.add(fn),
removeEventListener: (type, fn) => type === "keydown" && docKeys.delete(fn),
};
globalThis.window = { setTimeout, clearTimeout, addEventListener: () => {} };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
if (full.endsWith(".json")) {
const data = JSON.parse(fs.readFileSync(full, "utf8"));
cache.set(full, data);
return data;
}
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const pick = loadTs(B06("B06_Section_UI_Cross_Design_Pick"));
const fit = loadTs(B06("logic/B06_Section_UI_Cross_Fit"));
const { appendCrossDesignOverlay } = loadTs(B06("B06_Section_UI_Cross_Design"));
const { computeCrossDesign } = loadTs(
path.join(ROOT, "common_util", "common_util_cross_design.ts"),
);
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
const out = {};
// 마루 지반 — 우측 −4 m 까지 평지(노면보다 높음 = 절토) · 그 너머 1:0.6 내리막
const samples = Array.from({ length: 49 }, (_, i) => {
const o = -12 + i * 0.5;
return { offset_m: o, elevation_m: 100.3 - 0.6 * Math.max(0, -o - 4), valid: true };
});
const groundAt = (o) => 100.3 - 0.6 * Math.max(0, -o - 4);
const designOf = (extendRight) => {
const result = computeCrossDesign(samples, 100, {
standard,
groundType: "soil",
sectionMode: "right_cut",
ditchSide: "right",
shoulderExtendRightM: extendRight,
});
// 화면 design 칸 이름(저장 · 응답과 같음)
return { ...result, shoulder_extend_right_m: extendRight, shoulder_extend_left_m: 0 };
};
const section = (extendRight) => ({
station_id: "S1",
chainage_m: 20,
samples,
design: designOf(extendRight),
});
// ① 멈춤 — 우측 노견 끝이 원지반을 지나는 한 걸음 사이 → 그 자리
const gapAt = (design, e) => {
const { left, right } = design.road_edges;
const o = -(Math.abs(right.offset_m) - (design.shoulder_extend_right_m ?? 0) + e);
const plane =
right.elevation_m +
((left.elevation_m - right.elevation_m) * (o - right.offset_m)) /
(left.offset_m - right.offset_m);
return groundAt(o) - plane;
};
const base = designOf(0);
let crossing = null;
for (let e = 0; e < 6; e = Math.round((e + 0.1) * 10) / 10) {
if (Math.sign(gapAt(base, e)) !== Math.sign(gapAt(base, e + 0.1))) {
crossing = [e, Math.round((e + 0.1) * 10) / 10];
break;
}
}
const stop = fit.extendStop(
samples,
designOf(crossing[0]).road_edges,
"right",
crossing[0],
crossing[1],
);
out.stop = {
crossing,
value: stop,
gap: Math.abs(gapAt(base, stop)),
noCross: fit.extendStop(samples, base.road_edges, "right", 0, 0.1),
// 멈춘 자리에서 다음 걸음은 그대로 감(다시 안 섬)
fromStop: fit.extendStop(
samples,
designOf(stop).road_edges,
"right",
stop,
Math.round((stop + 0.1) * 10) / 10,
),
leftNoGround: fit.extendStop([], base.road_edges, "left", 0, 0.1),
};
// 370-4 — 0.001 m 반올림이 교차 **앞**에 떨어진 멈춤(54+0 0.936 꼴) → 다음 걸음은 넘어감(같은 교차에 다시 안 섬)
{
const [ga, gb] = crossing.map((e) => gapAt(base, e));
const exact = crossing[0] + ((crossing[1] - crossing[0]) * ga) / (ga - gb);
let before = Math.floor(exact * 1000) / 1000;
if (before === exact) before -= 0.001;
const next = Math.round((before + 0.1) * 1000) / 1000;
out.before = {
exact,
before,
gapMm: Math.abs(gapAt(base, before)) * 1000,
next,
got: fit.extendStop(samples, designOf(before).road_edges, "right", before, next),
};
}
// ② 교차점 손잡이 — 바뀜 자리(노견 끝 = 원지반) 쪽만 원 · 키 toe:left|right · 고르면 ◀▶ 로 절 ↔ 성(414-1)
const x = (o) => 300 - o * 20;
const y = (e) => 400 - (e - 100) * 20;
const attach = (sec, changes) => {
const layer = new FakeNode("g");
const from = layer.childElementCount;
appendCrossDesignOverlay(layer, sec.design, x, y, sec.samples, undefined);
layer.children.slice(from).forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
const picks = [];
const handle = pick.attachDesignLinePick({
layer,
design: sec.design,
x,
toDisplayY: y,
onPick: (i) => picks.push(i),
...pick.designLineMoves(sec, (_chainage, change) => changes.push(change)),
});
return { layer, handle, picks };
};
const toesOf = (layer) => layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
const near = section(crossing[0]); // 보통 단면(우 = 절토 · 바뀜 자리 아님)
const nearToes = toesOf(attach(near, []).layer);
const changes = [];
const bareSec = section(out.stop.value); // 바뀜 자리(우 노견 끝 = 원지반)
const { layer, handle, picks } = attach(bareSec, changes);
const toes = toesOf(layer);
const moves = pick.designLineMoves(bareSec, () => undefined);
const keys = moves.toes;
const rightToeHit = toes.find((t) => Number(t.getAttribute("cx")) > x(0));
out.toe = {
nearCount: nearToes.length,
nearSides: ["left", "right"].filter((s) => pick.designLineMoves(near, () => undefined).toes[s]),
count: toes.length,
bareSides: ["left", "right"].filter((s) => keys[s] && moves.bare[s]),
centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")),
rightOnGround:
keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9,
rightCx: rightToeHit ? Number(rightToeHit.getAttribute("cx")) : null,
rightX: keys.right ? x(keys.right.offset_m) : null,
};
rightToeHit.click();
handle.setActive(picks[0]);
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(끝 = 값 글)
const cellOf = (h) => ({
title: h.cell.children[0].textContent,
value: h.cell.children[1].children.at(-1).textContent,
button: (slot) =>
h.cell.children[1].children.find((k) =>
k.classList.contains(`b06-cross-card__cell-btn--${slot}`),
),
});
const cell = cellOf(handle);
cell.button(x(-1) > x(0) ? "right" : "left").click(); // 화면 → 바깥(우 = −offset)
cell.button(x(-1) > x(0) ? "left" : "right").click(); // 화면 → 안쪽
out.toePick = {
picked: picks,
active: rightToeHit.classList.contains("is-active"),
modeBefore: bareSec.design.section_mode,
modes: changes.map((c) => section(c.shoulder_extend.valueM).design.section_mode),
value: cell.value,
title: cell.title,
panelOpen: !handle.cell.classList.contains("is-empty"),
sent: changes.map((c) => c.shoulder_extend),
};
// ③ 고름 이어받기 — 우 절토 사면을 고른 뒤 마루 너머로 옮기면(키 cut → fill) 같은 쪽 손잡이
const cutIndex = handle.pieces.findIndex(
(p) =>
pick.pieceZone(near.design, p).zone === "cut" &&
pick.pieceZone(near.design, p).side === "right",
);
pick.setPickedLine("S1", null);
attach(near, []); // 키 적음
pick.setPickedLine("S1", cutIndex);
const before = pick.pickedLineAt("S1");
const far = section(crossing[1] + 1.0);
const after = attach(far, []);
const afterIndex = pick.pickedLineAt("S1");
out.inherit = {
cutIndex,
before,
farMode: far.design.section_mode,
afterIndex,
};
after.handle.setActive(afterIndex);
out.inherit.label = cellOf(after.handle).title;
// ⑤ 370-5 — 손잡이를 고른 뒤 넘어간 사면이 원지반과 못 만나면(손잡이 없음) 같은 쪽 첫 사면 선이 이어받음
pick.setPickedLine("S1", null);
{
const again = attach(bareSec, []);
toesOf(again.layer)
.find((t) => Number(t.getAttribute("cx")) > x(0))
.click();
pick.setPickedLine("S1", again.picks[0]);
let extend = crossing[1];
let open = null;
while (extend < 10 && !open) {
extend = Math.round((extend + 0.5) * 10) / 10;
const sec = section(extend);
if (!pick.designLineMoves(sec, () => undefined).toes.right) open = sec;
}
const lost = attach(open, []);
const index = pick.pickedLineAt("S1");
const piece = index === null ? null : lost.handle.pieces[index];
lost.handle.setActive(index);
const cell = cellOf(lost.handle);
out.noToe = {
extend,
toeGone: !!open,
index,
zone: piece && pick.pieceZone(open.design, piece),
title: cell.title,
buttons: ["left", "reset", "right"].filter((slot) => cell.button(slot)),
};
}
// ④ 정확히 0 자리(379-1) — 노견 끝 = 원지반(±5 mm = 절·성토 없음) · 손잡이 · 누르면 사면처럼 ◀↺▶ · 방향키
// 조각 0 은 안 봄 — 그림 규칙(노견 끝 → 첫 접점 보호 구간)이 지반 위 짧은 선을 남김(브레인 A)
const zero = section(out.stop.value);
const zeroPicks = [];
const zeroLayer = new FakeNode("g");
appendCrossDesignOverlay(zeroLayer, zero.design, x, y, zero.samples, undefined);
zeroLayer.children.forEach((el) => el.setAttribute(pick.DESIGN_LINE_ATTR, ""));
const zeroHandle = pick.attachDesignLinePick({
layer: zeroLayer,
design: zero.design,
x,
toDisplayY: y,
onPick: (i) => zeroPicks.push(i),
...pick.designLineMoves(zero, () => undefined),
});
const zeroToes = zeroLayer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
const rightToe = zeroToes.find((t) => Number(t.getAttribute("cx")) > x(0));
docKeys.clear();
rightToe?.click();
zeroHandle.setActive(zeroPicks[0]);
const zeroCell = cellOf(zeroHandle);
out.zero = {
bare: pick.designLineMoves(zero, () => undefined).bare,
nearBare: pick.designLineMoves(near, () => undefined).bare,
toe: !!rightToe,
radius: rightToe ? rightToe.getAttribute("r") : null,
title: zeroCell.title,
value: zeroCell.value,
buttons: ["left", "reset", "right"].filter((slot) => zeroCell.button(slot)),
keys: docKeys.size,
};
process.stdout.write(JSON.stringify(out));