Files
Aislo/resources/tester/helper_370_2_toe_stop.cjs
T
eomsangdonandClaude Opus 5.5 e9908f3976 fix(B06 횡단): 멈춤 자리에서 다음 걸음 넘어감 — 멈춤 판정 ±5 mm 한 잣대 (370-4)
- extendStop 「이미 멈춤 자리」 = |틈| ≤ 5 mm(옛 1e-4) — 0.001 m 반올림이 교차 앞에 떨어져도 같은 교차에 다시 안 섬
- 잣대 상수 ON_GROUND_M 하나 = extendStop · edgeOnGround(「절·성토 없음」) 같이 씀
- 시험: helper_370_2 「반올림이 교차 앞」 사례(2.736 → 2.836)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-17 15:59:27 +09:00

354 lines
12 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 · 고르면 ◀▶ 가 멈춤 자리로 보냄
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 changes = [];
const near = section(crossing[0]);
const { layer, handle, picks } = attach(near, changes);
const toes = layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
const keys = pick.designLineMoves(near, () => undefined).toes;
out.toe = {
count: toes.length,
centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")),
sides: ["left", "right"].filter((s) => keys[s]),
rightOnGround:
keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9,
rightCx: toes.length === 2 ? Number(toes[1].getAttribute("cx")) : null,
rightX: keys.right ? x(keys.right.offset_m) : null,
};
toes[1].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)
out.toePick = {
picked: picks,
active: toes[1].classList.contains("is-active"),
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;
// ④ 정확히 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));