/* 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));