Files
Aislo/resources/tester/helper_370_2_toe_stop.cjs
T
eomsangdonandClaude Opus 5.5 6cac1b7cd4 feat(B06 횡단): 사면 이동 멈춤 · 교차점 손잡이 · 고름 이어받기 (370-2)
- logic/B06_Section_UI_Cross_Fit.ts — extendStop(노견 끝 = 원지반 자리 보간 · 0.001 m) · toePoints(교차점 자리)
- Design_Pick — ◀▶ 한 걸음이 그 자리를 지나면 한 번 섬(↺ 그대로) · 쪽마다 교차점 원(키 toe:left|right · 늘 보임 · 고르면 ◀▶) · 사면 키가 절 ↔ 성으로 바뀌거나 사라지면 같은 쪽 손잡이가 이어받음
- 손잡이 CSS(Areas.css) · Node 시험 test_370_2_toe_stop.py + helper
- 서버 묶음 셋 다시 지음(b07-extent 가 Fit 을 실음)

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

301 lines
9.9 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));
}
}
globalThis.document = {
createElement: (tag) => new FakeNode(tag),
createElementNS: (_ns, tag) => new FakeNode(tag),
addEventListener: () => {},
removeEventListener: () => {},
};
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),
};
// ② 교차점 손잡이 — 쪽마다 원 · 늘 보임 · 키 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]);
const right = handle.panel.children.find((k) =>
k.classList?.contains("b06-structure-panel__struct"),
);
const buttons = right.children[1].children;
const rightButton = x(-1) > x(0) ? buttons[2] : buttons[0]; // 화면 → 바깥(우 = −offset)
rightButton.click();
out.toePick = {
picked: picks,
active: toes[1].classList.contains("is-active"),
label: handle.label.textContent,
title: handle.panel.children[0].children[0].textContent,
panelOpen: !handle.panel.classList.contains("is-hidden"),
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 = after.handle.label.textContent;
process.stdout.write(JSON.stringify(out));