bambuddy_adapter.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. /**
  2. * bambuddy_adapter.js
  3. * Bridges OctoPrint-PrettyGCode to Bambuddy's API.
  4. *
  5. * Load this BEFORE prettygcode.js. It provides:
  6. * - OCTOPRINT_VIEWMODELS shim
  7. * - Minimal KnockoutJS observable shim (ko.observable)
  8. * - fetch() + XHR interceptors for path rewriting
  9. * - Bambuddy WebSocket → fromCurrentData bridge
  10. * - File picker backed by Bambuddy's library API
  11. * - Settings load/save via plugin settings endpoint
  12. *
  13. * What works:
  14. * - Full 3D GCode visualisation
  15. * - Dark mode and all dat.GUI settings
  16. * - File selection from Bambuddy's file library
  17. * - Print progress highlight (% based)
  18. * - Auto-load currently printing file
  19. *
  20. * What doesn't work (Bambu hardware limitation):
  21. * - Live nozzle animation during printing — Bambu printers do not expose
  22. * GCode serial echo logs (Send: G1 X...), so PrintHeadSimulator has no input.
  23. */
  24. (function () {
  25. 'use strict';
  26. const API_BASE = '/api/v1';
  27. const VIEWER_BASE = '/gcode-viewer'; // static assets now served from here
  28. // -------------------------------------------------------------------------
  29. // Auth helper
  30. // -------------------------------------------------------------------------
  31. function authHeaders() {
  32. // sessionStorage is used when the user opts out of "remember me";
  33. // fall back to localStorage for persistent sessions.
  34. const token = sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
  35. return token ? { Authorization: 'Bearer ' + token } : {};
  36. }
  37. // When auth is enabled and the user has no valid token, every API call
  38. // returns 401 and the viewer chrome stays on screen showing empty state.
  39. // Intercept the first 401 and hand control back to the SPA, which owns
  40. // the login flow and will redirect to /login when appropriate.
  41. let _authRedirectFired = false;
  42. function apiFetch(path, opts) {
  43. return fetch(API_BASE + path, {
  44. ...opts,
  45. headers: { ...authHeaders(), ...(opts && opts.headers) },
  46. cache: 'no-store',
  47. }).then((response) => {
  48. if (response.status === 401 && !_authRedirectFired) {
  49. _authRedirectFired = true;
  50. try {
  51. sessionStorage.removeItem('auth_token');
  52. localStorage.removeItem('auth_token');
  53. } catch (e) { /* storage unavailable */ }
  54. window.top.location.replace('/');
  55. }
  56. return response;
  57. });
  58. }
  59. // -------------------------------------------------------------------------
  60. // 1. Minimal KnockoutJS shim (ko.observable / ko.computed)
  61. // -------------------------------------------------------------------------
  62. window.ko = {
  63. observable: function (initial) {
  64. var _val = initial;
  65. var _subs = [];
  66. var obs = function (newVal) {
  67. if (arguments.length > 0) {
  68. _val = newVal;
  69. _subs.forEach(function (cb) { try { cb(newVal); } catch (e) {} });
  70. }
  71. return _val;
  72. };
  73. obs.subscribe = function (cb) {
  74. _subs.push(cb);
  75. return { dispose: function () { _subs = _subs.filter(function (s) { return s !== cb; }); } };
  76. };
  77. obs.peek = function () { return _val; };
  78. return obs;
  79. },
  80. computed: function (fn) {
  81. var obs = window.ko.observable(null);
  82. try { obs(fn()); } catch (e) {}
  83. return obs;
  84. },
  85. pureComputed: function (fn) { return window.ko.computed(fn); },
  86. mapping: { fromJS: function (obj) { return obj; } },
  87. };
  88. // -------------------------------------------------------------------------
  89. // 2. OCTOPRINT_VIEWMODELS registration shim
  90. // -------------------------------------------------------------------------
  91. window.OCTOPRINT_VIEWMODELS = [];
  92. // -------------------------------------------------------------------------
  93. // 3. Fake OctoPrint settings / printer profile / login viewmodels
  94. // -------------------------------------------------------------------------
  95. var fakeSettings = {
  96. webcam: {
  97. streamUrl: ko.observable(''),
  98. flipH: ko.observable(false),
  99. flipV: ko.observable(false),
  100. rotate90: ko.observable(false),
  101. },
  102. plugins: {
  103. prettygcode: {
  104. darkMode: ko.observable(false),
  105. },
  106. },
  107. };
  108. // Bed sizes for common Bambu models (mm)
  109. // Fallback bed size used until loadArchiveById() fetches the archive's
  110. // actual build_volume from /api/v1/archives/{id}/capabilities.
  111. var DEFAULT_BED = { width: 256, depth: 256, height: 256 };
  112. var currentBed = Object.assign({}, DEFAULT_BED);
  113. function makeFakeProfileData(bed) {
  114. return {
  115. volume: {
  116. width: ko.observable(bed.width),
  117. depth: ko.observable(bed.depth),
  118. height: ko.observable(bed.height),
  119. origin: ko.observable('lowerleft'),
  120. formFactor: ko.observable('rectangular'),
  121. // Make custom_box a function so prettygcode.js uses width()/depth()/height()
  122. custom_box: function () { return false; },
  123. },
  124. };
  125. }
  126. var fakePrinterProfiles = {
  127. currentProfileData: ko.observable(makeFakeProfileData(currentBed)),
  128. };
  129. var fakeLoginState = {
  130. isUser: ko.observable(true),
  131. isAdmin: ko.observable(false),
  132. };
  133. var fakeControl = {};
  134. // -------------------------------------------------------------------------
  135. // 4. fetch() interceptor — rewrite OctoPrint paths to Bambuddy
  136. // -------------------------------------------------------------------------
  137. var _originalFetch = window.fetch.bind(window);
  138. window.fetch = function (resource, init) {
  139. var url = (typeof resource === 'string') ? resource
  140. : (resource && resource.url) ? resource.url
  141. : null;
  142. if (url) {
  143. // Normalize: strip scheme+host so regexes work on the path regardless
  144. // of whether the browser resolved a relative URL to absolute.
  145. var path = url.replace(/^https?:\/\/[^\/]+/, '');
  146. // Also strip the viewer's own path prefix — the browser resolves relative URLs
  147. // like 'downloads/files/local/...' to '/gcode-viewer/downloads/...' because
  148. // the page is served from /gcode-viewer/. The regexes below expect bare paths.
  149. path = path.replace(/^\/gcode-viewer(?=\/|$)/, '');
  150. var newPath = path;
  151. // OctoPrint file download → Bambuddy library download
  152. newPath = newPath.replace(
  153. /^\/?downloads\/files\/local\/__bambuddy_file_(\d+)$/,
  154. API_BASE + '/library/files/$1/download'
  155. );
  156. // OctoPrint file download → Bambuddy archive gcode (specific plate)
  157. newPath = newPath.replace(
  158. /^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)_plate(\d+)$/,
  159. API_BASE + '/archives/$1/gcode?plate=$2'
  160. );
  161. // OctoPrint file download → Bambuddy archive gcode (first plate)
  162. newPath = newPath.replace(
  163. /^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)$/,
  164. API_BASE + '/archives/$1/gcode'
  165. );
  166. // OctoPrint plugin static assets → gcode-viewer static files
  167. newPath = newPath.replace(
  168. /^\/?plugin\/prettygcode\/static\//,
  169. VIEWER_BASE + '/'
  170. );
  171. if (newPath !== path) {
  172. url = newPath;
  173. resource = url; // always pass as string after rewriting
  174. }
  175. // Inject auth header for all Bambuddy API calls
  176. if (url.startsWith(API_BASE)) {
  177. var hdrs = authHeaders();
  178. init = init || {};
  179. init.headers = Object.assign({}, hdrs, init.headers || {});
  180. }
  181. }
  182. var promise = _originalFetch(resource, init);
  183. // Tee GCode downloads to build the layer map for sync + nozzle animation
  184. if (url && (url.match(/\/library\/files\/\d+\/download/) || url.match(/\/archives\/\d+\/gcode/))) {
  185. promise = promise.then(function (response) {
  186. var clone = response.clone();
  187. clone.text().then(function (text) {
  188. gcodeLayerMap = parseGcodeLayerMap(text);
  189. lastFedLayer = -1;
  190. console.log('[PrettyGCode] Parsed ' + gcodeLayerMap.layerOffsets.length +
  191. ' layers for sync (' + Math.round(gcodeLayerMap.totalBytes / 1024) + ' KB)');
  192. }).catch(function (e) {
  193. console.warn('[PrettyGCode] GCode layer parse failed:', e);
  194. });
  195. return response;
  196. });
  197. }
  198. return promise;
  199. };
  200. // -------------------------------------------------------------------------
  201. // 5. XHR interceptor — rewrite OctoPrint paths (used by THREE.OBJLoader etc.)
  202. // -------------------------------------------------------------------------
  203. var _origXHROpen = XMLHttpRequest.prototype.open;
  204. XMLHttpRequest.prototype.open = function (method, url) {
  205. if (typeof url === 'string') {
  206. // Strip host if absolute, then rewrite OctoPrint static asset paths
  207. var path = url.replace(/^https?:\/\/[^\/]+/, '');
  208. path = path.replace(/^\/?plugin\/prettygcode\/static\//, VIEWER_BASE + '/');
  209. url = path;
  210. }
  211. var args = Array.prototype.slice.call(arguments);
  212. args[1] = url;
  213. return _origXHROpen.apply(this, args);
  214. };
  215. // -------------------------------------------------------------------------
  216. // 6. GCode layer parser
  217. //
  218. // Builds a layer map from the raw GCode text so we can:
  219. // a) Map layer_num → byte offset in file (drives prettygcode's filepos sync
  220. // and layer highlight, same as if OctoPrint were reporting filepos)
  221. // b) Extract a set of G0/G1 commands per layer to feed the PrintHeadSimulator
  222. // as synthetic "Send: G1 X... Y... Z..." entries, animating the nozzle model.
  223. //
  224. // Layer detection mirrors prettygcode.js: a new layer starts on the first extrusion
  225. // at a Z position we haven't extruded at before.
  226. // -------------------------------------------------------------------------
  227. function parseGcodeLayerMap(text) {
  228. var lines = text.split('\n');
  229. var layerOffsets = []; // layerOffsets[i] = byte pos in file where layer i starts
  230. var layerCmds = []; // layerCmds[i] = array of ' G1 X... Y... Z...' strings
  231. var byteOffset = 0;
  232. var x = 0, y = 0, z = 0, e = 0;
  233. var relative = false, relativeE = false;
  234. var currentLayerZ = null;
  235. var curCmds = [];
  236. for (var i = 0; i < lines.length; i++) {
  237. var raw = lines[i];
  238. // +1 for the \n that was consumed by split
  239. var lineBytes = raw.length + 1;
  240. var cmd = raw.replace(/;.*$/, '').trim();
  241. if (!cmd) { byteOffset += lineBytes; continue; }
  242. var parts = cmd.split(/\s+/);
  243. var g = parts[0].toUpperCase();
  244. if (g === 'G90') { relative = false; relativeE = false; }
  245. else if (g === 'G91') { relative = true; relativeE = true; }
  246. else if (g === 'M82') { relativeE = false; }
  247. else if (g === 'M83') { relativeE = true; }
  248. else if (g === 'G92') {
  249. // coordinate reset
  250. for (var p = 1; p < parts.length; p++) {
  251. var k0 = parts[p][0].toUpperCase();
  252. var v0 = parseFloat(parts[p].slice(1));
  253. if (!isNaN(v0)) {
  254. if (k0 === 'X') x = v0;
  255. else if (k0 === 'Y') y = v0;
  256. else if (k0 === 'Z') z = v0;
  257. else if (k0 === 'E') e = v0;
  258. }
  259. }
  260. } else if (g === 'G0' || g === 'G1') {
  261. var nx = x, ny = y, nz = z, ne = e;
  262. var hasE = false;
  263. for (var p = 1; p < parts.length; p++) {
  264. if (!parts[p]) continue;
  265. var k1 = parts[p][0].toUpperCase();
  266. var v1 = parseFloat(parts[p].slice(1));
  267. if (isNaN(v1)) continue;
  268. if (k1 === 'X') nx = relative ? x + v1 : v1;
  269. else if (k1 === 'Y') ny = relative ? y + v1 : v1;
  270. else if (k1 === 'Z') nz = relative ? z + v1 : v1;
  271. else if (k1 === 'E') { ne = relativeE ? e + v1 : v1; hasE = true; }
  272. }
  273. // New layer: first extrusion at a new Z (same logic as prettygcode.js)
  274. if (hasE && ne > e && nz !== currentLayerZ) {
  275. currentLayerZ = nz;
  276. if (curCmds.length > 0) layerCmds.push(curCmds);
  277. else if (layerOffsets.length > 0) layerCmds.push([]); // gap layer
  278. curCmds = [];
  279. layerOffsets.push(byteOffset);
  280. }
  281. // Record movement commands for nozzle sim (keep arrays small — max 500/layer)
  282. if ((hasE || nz !== z) && curCmds.length < 500) {
  283. curCmds.push(' G1 X' + nx.toFixed(3) +
  284. ' Y' + ny.toFixed(3) +
  285. ' Z' + nz.toFixed(3));
  286. }
  287. x = nx; y = ny; z = nz; e = ne;
  288. }
  289. byteOffset += lineBytes;
  290. }
  291. if (curCmds.length > 0) layerCmds.push(curCmds);
  292. return {
  293. layerOffsets: layerOffsets,
  294. layerCmds: layerCmds,
  295. totalBytes: byteOffset,
  296. };
  297. }
  298. // -------------------------------------------------------------------------
  299. // 8. State
  300. // -------------------------------------------------------------------------
  301. var viewModel = null;
  302. var currentFileId = null;
  303. var currentFilename = null;
  304. var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
  305. var gcodeLayerMap = null; // parsed layer data: {layerOffsets, layerCmds, totalBytes}
  306. var lastFedLayer = -1; // last layer_num whose commands we fed to printHeadSim
  307. // The viewer is scoped to previewing a specific archive (/gcode-viewer?archive=<id>).
  308. // It no longer observes live printer state, so the WebSocket connection, the
  309. // printer selector, auto-load-currently-printing, and library file picker are all
  310. // intentionally absent. Bed size is derived from the archive's sliced_for_model.
  311. function updateFilenameDisplay(filename) {
  312. var el = document.getElementById('bb-current-file');
  313. if (el) el.textContent = filename || '— no file loaded —';
  314. }
  315. // -------------------------------------------------------------------------
  316. // 10. Archive loader — invoked via /gcode-viewer/?archive=<id>
  317. // -------------------------------------------------------------------------
  318. function loadArchiveById(archiveId, plate) {
  319. // Pretygcode downloads /downloads/files/local/__bambuddy_archive_<id>(_plate<N>)
  320. // and the fetch intercept rewrites it to /api/v1/archives/<id>/gcode[?plate=N].
  321. var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
  322. currentFileId = 'archive_' + archiveId + plateSuffix;
  323. currentFilename = 'Archive #' + archiveId + (plateSuffix ? (' (plate ' + plate + ')') : '');
  324. currentFileDate = Date.now();
  325. gcodeLayerMap = null;
  326. lastFedLayer = -1;
  327. stopPlayback(true);
  328. updateFilenameDisplay(currentFilename);
  329. var playBtn = document.getElementById('bb-play-btn');
  330. if (playBtn) playBtn.disabled = false;
  331. if (viewModel && viewModel.fromCurrentData) {
  332. viewModel.fromCurrentData({
  333. job: {
  334. file: {
  335. path: '__bambuddy_archive_' + archiveId + plateSuffix,
  336. date: currentFileDate,
  337. },
  338. estimatedPrintTime: null,
  339. },
  340. state: { text: 'Operational', flags: { printing: false } },
  341. progress: { filepos: null, completion: 0 },
  342. currentZ: null,
  343. logs: [],
  344. });
  345. }
  346. // Fetch metadata (for the filename display) and capabilities (for the
  347. // bed size) in parallel. Capabilities extracts the actual build_volume
  348. // from the 3MF's slicer config (printable_area / printable_height), so
  349. // the bed matches whatever hardware the archive was sliced for — no
  350. // hardcoded per-model map, correct for H2D (350×320×325), H-family
  351. // machines, and any future model.
  352. apiFetch('/archives/' + archiveId, {})
  353. .then(function (r) { return r.ok ? r.json() : null; })
  354. .then(function (meta) {
  355. if (meta && (meta.print_name || meta.filename)) {
  356. currentFilename = (meta.print_name || meta.filename) +
  357. (plateSuffix ? (' (plate ' + plate + ')') : '');
  358. updateFilenameDisplay(currentFilename);
  359. }
  360. })
  361. .catch(function () { /* best-effort — filename stays "Archive #N" */ });
  362. apiFetch('/archives/' + archiveId + '/capabilities', {})
  363. .then(function (r) { return r.ok ? r.json() : null; })
  364. .then(function (caps) {
  365. if (!caps || !caps.build_volume) return;
  366. var bv = caps.build_volume;
  367. if (bv.x > 0 && bv.y > 0 && bv.z > 0) {
  368. currentBed = { width: bv.x, depth: bv.y, height: bv.z };
  369. fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
  370. }
  371. })
  372. .catch(function () { /* best-effort — default bed stays */ });
  373. }
  374. // -------------------------------------------------------------------------
  375. // 11. Initialise after DOM + scripts are ready
  376. // -------------------------------------------------------------------------
  377. function init() {
  378. // Find the ViewModel registration that prettygcode.js pushed
  379. var reg = null;
  380. for (var i = 0; i < window.OCTOPRINT_VIEWMODELS.length; i++) {
  381. if (window.OCTOPRINT_VIEWMODELS[i].construct) {
  382. reg = window.OCTOPRINT_VIEWMODELS[i];
  383. break;
  384. }
  385. }
  386. if (!reg) {
  387. console.error('[PrettyGCode] No ViewModel found in OCTOPRINT_VIEWMODELS');
  388. return;
  389. }
  390. try {
  391. viewModel = new reg.construct([
  392. fakeSettings,
  393. fakeLoginState,
  394. fakePrinterProfiles,
  395. fakeControl,
  396. ]);
  397. } catch (e) {
  398. console.error('[PrettyGCode] ViewModel constructor failed:', e);
  399. return;
  400. }
  401. if (viewModel.onAfterBinding) {
  402. try { viewModel.onAfterBinding(); } catch (e) {}
  403. }
  404. // Trigger tab activation — this calls onTabChange which initialises the Three.js scene
  405. if (viewModel.onTabChange) {
  406. try { viewModel.onTabChange('#tab_plugin_prettygcode', ''); } catch (e) {
  407. console.error('[PrettyGCode] onTabChange failed:', e);
  408. }
  409. }
  410. console.log('[PrettyGCode] Bambuddy adapter initialised');
  411. // Wire up playback controls
  412. var playBtn = document.getElementById('bb-play-btn');
  413. var speedSel = document.getElementById('bb-play-speed');
  414. if (playBtn) {
  415. playBtn.addEventListener('click', function () {
  416. if (isPlaying) stopPlayback();
  417. else startPlayback();
  418. });
  419. }
  420. if (speedSel) {
  421. speedSel.addEventListener('change', function () {
  422. layersPerTick = parseInt(speedSel.value, 10) || 1;
  423. // Restart if already playing so speed takes effect immediately
  424. if (isPlaying) { stopPlayback(); startPlayback(); }
  425. });
  426. }
  427. }
  428. // -------------------------------------------------------------------------
  429. // 14. Playback engine
  430. // -------------------------------------------------------------------------
  431. var isPlaying = false;
  432. var playInterval = null;
  433. var layersPerTick = 1; // layers advanced per 50 ms tick
  434. var TICK_MS = 50; // ~20 fps
  435. function getSlider() { return $('#myslider-vertical'); }
  436. function startPlayback() {
  437. var $sl = getSlider();
  438. if (!$sl.length) return;
  439. var data = $sl.data('_pgslider');
  440. if (!data) return;
  441. var max = data.opts.max || 0;
  442. if (max === 0) return;
  443. // Restart from beginning if already at the end
  444. var cur = data.opts.value || 0;
  445. if (cur >= max) cur = 0;
  446. // Suppress live-print sync while playing
  447. var evStart = $.Event('slideStart'); evStart.value = cur; $sl.trigger(evStart);
  448. _setSliderLayer($sl, cur);
  449. isPlaying = true;
  450. _updatePlayBtn();
  451. playInterval = setInterval(function () {
  452. var d = getSlider().data('_pgslider');
  453. if (!d) { stopPlayback(); return; }
  454. var next = (d.opts.value || 0) + layersPerTick;
  455. if (next >= d.opts.max) {
  456. next = d.opts.max;
  457. _setSliderLayer(getSlider(), next);
  458. stopPlayback(/* skipEvStop */ false);
  459. return;
  460. }
  461. _setSliderLayer(getSlider(), next);
  462. }, TICK_MS);
  463. }
  464. function stopPlayback(skipEvStop) {
  465. if (playInterval) { clearInterval(playInterval); playInterval = null; }
  466. isPlaying = false;
  467. _updatePlayBtn();
  468. if (!skipEvStop) {
  469. var $sl = getSlider();
  470. if ($sl.length) {
  471. var d = $sl.data('_pgslider');
  472. var evStop = $.Event('slideStop');
  473. evStop.value = d ? d.opts.value : 0;
  474. $sl.trigger(evStop);
  475. }
  476. }
  477. }
  478. function _setSliderLayer($sl, layer) {
  479. $sl.slider('setValue', layer);
  480. var ev = $.Event('slide'); ev.value = layer; $sl.trigger(ev);
  481. $sl.find('.slider-handle').text(layer);
  482. }
  483. function _updatePlayBtn() {
  484. var btn = document.getElementById('bb-play-btn');
  485. if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
  486. }
  487. // Run after all scripts have loaded. init() (viewmodel + 3D canvas) runs
  488. // 200 ms later to let prettygcode.js finish its own synchronous setup first.
  489. function onDomReady() {
  490. setTimeout(function () {
  491. init();
  492. // If the viewer was opened with ?archive=<id>[&plate=<N>], load that
  493. // archive's gcode for the requested plate once the viewmodel is ready.
  494. try {
  495. var params = new URLSearchParams(window.location.search);
  496. var archiveParam = params.get('archive');
  497. var plateParam = params.get('plate');
  498. if (archiveParam && /^[1-9][0-9]*$/.test(archiveParam)) {
  499. var archiveId = parseInt(archiveParam, 10);
  500. var plateId = (plateParam && /^[1-9][0-9]*$/.test(plateParam))
  501. ? parseInt(plateParam, 10)
  502. : undefined;
  503. // Allow a tick for init() to finish wiring viewModel.fromCurrentData
  504. setTimeout(function () { loadArchiveById(archiveId, plateId); }, 50);
  505. }
  506. } catch (e) { /* URLSearchParams unsupported — skip */ }
  507. }, 200);
  508. }
  509. if (document.readyState === 'loading') {
  510. document.addEventListener('DOMContentLoaded', onDomReady);
  511. } else {
  512. onDomReady();
  513. }
  514. // -------------------------------------------------------------------------
  515. // Public API
  516. // -------------------------------------------------------------------------
  517. window.BambuddyPrettyGCode = {
  518. loadArchive: loadArchiveById,
  519. getViewModel: function () { return viewModel; },
  520. play: startPlayback,
  521. stop: stopPlayback,
  522. };
  523. })();