bambuddy_adapter.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648
  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 file download → Bambuddy library file gcode
  167. // (sliced LibraryFile — extracts embedded gcode from .gcode.3mf
  168. // or returns plain .gcode). Plate is ignored upstream for now.
  169. newPath = newPath.replace(
  170. /^\/?downloads\/files\/local\/__bambuddy_libgcode_(\d+)(?:_plate\d+)?$/,
  171. API_BASE + '/library/files/$1/gcode'
  172. );
  173. // OctoPrint plugin static assets → gcode-viewer static files
  174. newPath = newPath.replace(
  175. /^\/?plugin\/prettygcode\/static\//,
  176. VIEWER_BASE + '/'
  177. );
  178. if (newPath !== path) {
  179. url = newPath;
  180. resource = url; // always pass as string after rewriting
  181. }
  182. // Inject auth header for all Bambuddy API calls
  183. if (url.startsWith(API_BASE)) {
  184. var hdrs = authHeaders();
  185. init = init || {};
  186. init.headers = Object.assign({}, hdrs, init.headers || {});
  187. }
  188. }
  189. var promise = _originalFetch(resource, init);
  190. // Tee GCode downloads to build the layer map for sync + nozzle animation
  191. if (url && (url.match(/\/library\/files\/\d+\/download/) || url.match(/\/archives\/\d+\/gcode/))) {
  192. promise = promise.then(function (response) {
  193. var clone = response.clone();
  194. clone.text().then(function (text) {
  195. gcodeLayerMap = parseGcodeLayerMap(text);
  196. lastFedLayer = -1;
  197. console.log('[PrettyGCode] Parsed ' + gcodeLayerMap.layerOffsets.length +
  198. ' layers for sync (' + Math.round(gcodeLayerMap.totalBytes / 1024) + ' KB)');
  199. }).catch(function (e) {
  200. console.warn('[PrettyGCode] GCode layer parse failed:', e);
  201. });
  202. return response;
  203. });
  204. }
  205. return promise;
  206. };
  207. // -------------------------------------------------------------------------
  208. // 5. XHR interceptor — rewrite OctoPrint paths (used by THREE.OBJLoader etc.)
  209. // -------------------------------------------------------------------------
  210. var _origXHROpen = XMLHttpRequest.prototype.open;
  211. XMLHttpRequest.prototype.open = function (method, url) {
  212. if (typeof url === 'string') {
  213. // Strip host if absolute, then rewrite OctoPrint static asset paths
  214. var path = url.replace(/^https?:\/\/[^\/]+/, '');
  215. path = path.replace(/^\/?plugin\/prettygcode\/static\//, VIEWER_BASE + '/');
  216. url = path;
  217. }
  218. var args = Array.prototype.slice.call(arguments);
  219. args[1] = url;
  220. return _origXHROpen.apply(this, args);
  221. };
  222. // -------------------------------------------------------------------------
  223. // 6. GCode layer parser
  224. //
  225. // Builds a layer map from the raw GCode text so we can:
  226. // a) Map layer_num → byte offset in file (drives prettygcode's filepos sync
  227. // and layer highlight, same as if OctoPrint were reporting filepos)
  228. // b) Extract a set of G0/G1 commands per layer to feed the PrintHeadSimulator
  229. // as synthetic "Send: G1 X... Y... Z..." entries, animating the nozzle model.
  230. //
  231. // Layer detection mirrors prettygcode.js: a new layer starts on the first extrusion
  232. // at a Z position we haven't extruded at before.
  233. // -------------------------------------------------------------------------
  234. function parseGcodeLayerMap(text) {
  235. var lines = text.split('\n');
  236. var layerOffsets = []; // layerOffsets[i] = byte pos in file where layer i starts
  237. var layerCmds = []; // layerCmds[i] = array of ' G1 X... Y... Z...' strings
  238. var byteOffset = 0;
  239. var x = 0, y = 0, z = 0, e = 0;
  240. var relative = false, relativeE = false;
  241. var currentLayerZ = null;
  242. var curCmds = [];
  243. for (var i = 0; i < lines.length; i++) {
  244. var raw = lines[i];
  245. // +1 for the \n that was consumed by split
  246. var lineBytes = raw.length + 1;
  247. var cmd = raw.replace(/;.*$/, '').trim();
  248. if (!cmd) { byteOffset += lineBytes; continue; }
  249. var parts = cmd.split(/\s+/);
  250. var g = parts[0].toUpperCase();
  251. if (g === 'G90') { relative = false; relativeE = false; }
  252. else if (g === 'G91') { relative = true; relativeE = true; }
  253. else if (g === 'M82') { relativeE = false; }
  254. else if (g === 'M83') { relativeE = true; }
  255. else if (g === 'G92') {
  256. // coordinate reset
  257. for (var p = 1; p < parts.length; p++) {
  258. var k0 = parts[p][0].toUpperCase();
  259. var v0 = parseFloat(parts[p].slice(1));
  260. if (!isNaN(v0)) {
  261. if (k0 === 'X') x = v0;
  262. else if (k0 === 'Y') y = v0;
  263. else if (k0 === 'Z') z = v0;
  264. else if (k0 === 'E') e = v0;
  265. }
  266. }
  267. } else if (g === 'G0' || g === 'G1') {
  268. var nx = x, ny = y, nz = z, ne = e;
  269. var hasE = false;
  270. for (var p = 1; p < parts.length; p++) {
  271. if (!parts[p]) continue;
  272. var k1 = parts[p][0].toUpperCase();
  273. var v1 = parseFloat(parts[p].slice(1));
  274. if (isNaN(v1)) continue;
  275. if (k1 === 'X') nx = relative ? x + v1 : v1;
  276. else if (k1 === 'Y') ny = relative ? y + v1 : v1;
  277. else if (k1 === 'Z') nz = relative ? z + v1 : v1;
  278. else if (k1 === 'E') { ne = relativeE ? e + v1 : v1; hasE = true; }
  279. }
  280. // New layer: first extrusion at a new Z (same logic as prettygcode.js)
  281. if (hasE && ne > e && nz !== currentLayerZ) {
  282. currentLayerZ = nz;
  283. if (curCmds.length > 0) layerCmds.push(curCmds);
  284. else if (layerOffsets.length > 0) layerCmds.push([]); // gap layer
  285. curCmds = [];
  286. layerOffsets.push(byteOffset);
  287. }
  288. // Record movement commands for nozzle sim (keep arrays small — max 500/layer)
  289. if ((hasE || nz !== z) && curCmds.length < 500) {
  290. curCmds.push(' G1 X' + nx.toFixed(3) +
  291. ' Y' + ny.toFixed(3) +
  292. ' Z' + nz.toFixed(3));
  293. }
  294. x = nx; y = ny; z = nz; e = ne;
  295. }
  296. byteOffset += lineBytes;
  297. }
  298. if (curCmds.length > 0) layerCmds.push(curCmds);
  299. return {
  300. layerOffsets: layerOffsets,
  301. layerCmds: layerCmds,
  302. totalBytes: byteOffset,
  303. };
  304. }
  305. // -------------------------------------------------------------------------
  306. // 8. State
  307. // -------------------------------------------------------------------------
  308. var viewModel = null;
  309. var currentFileId = null;
  310. var currentFilename = null;
  311. var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
  312. var gcodeLayerMap = null; // parsed layer data: {layerOffsets, layerCmds, totalBytes}
  313. var lastFedLayer = -1; // last layer_num whose commands we fed to printHeadSim
  314. // The viewer is scoped to previewing a specific archive (/gcode-viewer?archive=<id>).
  315. // It no longer observes live printer state, so the WebSocket connection, the
  316. // printer selector, auto-load-currently-printing, and library file picker are all
  317. // intentionally absent. Bed size is derived from the archive's sliced_for_model.
  318. function updateFilenameDisplay(filename) {
  319. var el = document.getElementById('bb-current-file');
  320. if (el) el.textContent = filename || '— no file loaded —';
  321. }
  322. // -------------------------------------------------------------------------
  323. // 10. Archive loader — invoked via /gcode-viewer/?archive=<id>
  324. // -------------------------------------------------------------------------
  325. function loadArchiveById(archiveId, plate) {
  326. // Pretygcode downloads /downloads/files/local/__bambuddy_archive_<id>(_plate<N>)
  327. // and the fetch intercept rewrites it to /api/v1/archives/<id>/gcode[?plate=N].
  328. var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
  329. currentFileId = 'archive_' + archiveId + plateSuffix;
  330. currentFilename = 'Archive #' + archiveId + (plateSuffix ? (' (plate ' + plate + ')') : '');
  331. currentFileDate = Date.now();
  332. gcodeLayerMap = null;
  333. lastFedLayer = -1;
  334. stopPlayback(true);
  335. updateFilenameDisplay(currentFilename);
  336. var playBtn = document.getElementById('bb-play-btn');
  337. if (playBtn) playBtn.disabled = false;
  338. if (viewModel && viewModel.fromCurrentData) {
  339. viewModel.fromCurrentData({
  340. job: {
  341. file: {
  342. path: '__bambuddy_archive_' + archiveId + plateSuffix,
  343. date: currentFileDate,
  344. },
  345. estimatedPrintTime: null,
  346. },
  347. state: { text: 'Operational', flags: { printing: false } },
  348. progress: { filepos: null, completion: 0 },
  349. currentZ: null,
  350. logs: [],
  351. });
  352. }
  353. // Fetch metadata (for the filename display) and capabilities (for the
  354. // bed size) in parallel. Capabilities extracts the actual build_volume
  355. // from the 3MF's slicer config (printable_area / printable_height), so
  356. // the bed matches whatever hardware the archive was sliced for — no
  357. // hardcoded per-model map, correct for H2D (350×320×325), H-family
  358. // machines, and any future model.
  359. apiFetch('/archives/' + archiveId, {})
  360. .then(function (r) { return r.ok ? r.json() : null; })
  361. .then(function (meta) {
  362. if (meta && (meta.print_name || meta.filename)) {
  363. currentFilename = (meta.print_name || meta.filename) +
  364. (plateSuffix ? (' (plate ' + plate + ')') : '');
  365. updateFilenameDisplay(currentFilename);
  366. }
  367. })
  368. .catch(function () { /* best-effort — filename stays "Archive #N" */ });
  369. apiFetch('/archives/' + archiveId + '/capabilities', {})
  370. .then(function (r) { return r.ok ? r.json() : null; })
  371. .then(function (caps) {
  372. if (!caps || !caps.build_volume) return;
  373. var bv = caps.build_volume;
  374. if (bv.x > 0 && bv.y > 0 && bv.z > 0) {
  375. currentBed = { width: bv.x, depth: bv.y, height: bv.z };
  376. fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
  377. }
  378. })
  379. .catch(function () { /* best-effort — default bed stays */ });
  380. }
  381. // -------------------------------------------------------------------------
  382. // 10b. Library file loader — invoked via /gcode-viewer/?library_file=<id>
  383. // -------------------------------------------------------------------------
  384. function loadLibraryFileById(fileId, plate) {
  385. // Mirror loadArchiveById, but use the library gcode endpoint. The
  386. // /library/files/<id>/gcode endpoint extracts the embedded gcode
  387. // from a .gcode.3mf or returns a plain .gcode body. It does not
  388. // accept a plate selector today — multi-plate library files would
  389. // need an upstream change. The plate suffix here is preserved in
  390. // currentFilename for display only.
  391. var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
  392. currentFileId = 'libfile_' + fileId + plateSuffix;
  393. currentFilename = 'Library file #' + fileId + (plateSuffix ? (' (plate ' + plate + ')') : '');
  394. currentFileDate = Date.now();
  395. gcodeLayerMap = null;
  396. lastFedLayer = -1;
  397. stopPlayback(true);
  398. updateFilenameDisplay(currentFilename);
  399. var playBtn = document.getElementById('bb-play-btn');
  400. if (playBtn) playBtn.disabled = false;
  401. if (viewModel && viewModel.fromCurrentData) {
  402. viewModel.fromCurrentData({
  403. job: {
  404. file: {
  405. path: '__bambuddy_libgcode_' + fileId + plateSuffix,
  406. date: currentFileDate,
  407. },
  408. estimatedPrintTime: null,
  409. },
  410. state: { text: 'Operational', flags: { printing: false } },
  411. progress: { filepos: null, completion: 0 },
  412. currentZ: null,
  413. logs: [],
  414. });
  415. }
  416. // Fetch metadata for the filename display. There is no
  417. // /library/files/<id>/capabilities endpoint, so the bed stays at
  418. // whatever the default fakePrinterProfile is set to.
  419. apiFetch('/library/files/' + fileId, {})
  420. .then(function (r) { return r.ok ? r.json() : null; })
  421. .then(function (meta) {
  422. if (meta && (meta.print_name || meta.filename)) {
  423. currentFilename = (meta.print_name || meta.filename) +
  424. (plateSuffix ? (' (plate ' + plate + ')') : '');
  425. updateFilenameDisplay(currentFilename);
  426. }
  427. })
  428. .catch(function () { /* best-effort — filename stays "Library file #N" */ });
  429. }
  430. // -------------------------------------------------------------------------
  431. // 11. Initialise after DOM + scripts are ready
  432. // -------------------------------------------------------------------------
  433. function init() {
  434. // Find the ViewModel registration that prettygcode.js pushed
  435. var reg = null;
  436. for (var i = 0; i < window.OCTOPRINT_VIEWMODELS.length; i++) {
  437. if (window.OCTOPRINT_VIEWMODELS[i].construct) {
  438. reg = window.OCTOPRINT_VIEWMODELS[i];
  439. break;
  440. }
  441. }
  442. if (!reg) {
  443. console.error('[PrettyGCode] No ViewModel found in OCTOPRINT_VIEWMODELS');
  444. return;
  445. }
  446. try {
  447. viewModel = new reg.construct([
  448. fakeSettings,
  449. fakeLoginState,
  450. fakePrinterProfiles,
  451. fakeControl,
  452. ]);
  453. } catch (e) {
  454. console.error('[PrettyGCode] ViewModel constructor failed:', e);
  455. return;
  456. }
  457. if (viewModel.onAfterBinding) {
  458. try { viewModel.onAfterBinding(); } catch (e) {}
  459. }
  460. // Trigger tab activation — this calls onTabChange which initialises the Three.js scene
  461. if (viewModel.onTabChange) {
  462. try { viewModel.onTabChange('#tab_plugin_prettygcode', ''); } catch (e) {
  463. console.error('[PrettyGCode] onTabChange failed:', e);
  464. }
  465. }
  466. console.log('[PrettyGCode] Bambuddy adapter initialised');
  467. // Wire up playback controls
  468. var playBtn = document.getElementById('bb-play-btn');
  469. var speedSel = document.getElementById('bb-play-speed');
  470. if (playBtn) {
  471. playBtn.addEventListener('click', function () {
  472. if (isPlaying) stopPlayback();
  473. else startPlayback();
  474. });
  475. }
  476. if (speedSel) {
  477. speedSel.addEventListener('change', function () {
  478. layersPerTick = parseInt(speedSel.value, 10) || 1;
  479. // Restart if already playing so speed takes effect immediately
  480. if (isPlaying) { stopPlayback(); startPlayback(); }
  481. });
  482. }
  483. }
  484. // -------------------------------------------------------------------------
  485. // 14. Playback engine
  486. // -------------------------------------------------------------------------
  487. var isPlaying = false;
  488. var playInterval = null;
  489. var layersPerTick = 1; // layers advanced per 50 ms tick
  490. var TICK_MS = 50; // ~20 fps
  491. function getSlider() { return $('#myslider-vertical'); }
  492. function startPlayback() {
  493. var $sl = getSlider();
  494. if (!$sl.length) return;
  495. var data = $sl.data('_pgslider');
  496. if (!data) return;
  497. var max = data.opts.max || 0;
  498. if (max === 0) return;
  499. // Restart from beginning if already at the end
  500. var cur = data.opts.value || 0;
  501. if (cur >= max) cur = 0;
  502. // Suppress live-print sync while playing
  503. var evStart = $.Event('slideStart'); evStart.value = cur; $sl.trigger(evStart);
  504. _setSliderLayer($sl, cur);
  505. isPlaying = true;
  506. _updatePlayBtn();
  507. playInterval = setInterval(function () {
  508. var d = getSlider().data('_pgslider');
  509. if (!d) { stopPlayback(); return; }
  510. var next = (d.opts.value || 0) + layersPerTick;
  511. if (next >= d.opts.max) {
  512. next = d.opts.max;
  513. _setSliderLayer(getSlider(), next);
  514. stopPlayback(/* skipEvStop */ false);
  515. return;
  516. }
  517. _setSliderLayer(getSlider(), next);
  518. }, TICK_MS);
  519. }
  520. function stopPlayback(skipEvStop) {
  521. if (playInterval) { clearInterval(playInterval); playInterval = null; }
  522. isPlaying = false;
  523. _updatePlayBtn();
  524. if (!skipEvStop) {
  525. var $sl = getSlider();
  526. if ($sl.length) {
  527. var d = $sl.data('_pgslider');
  528. var evStop = $.Event('slideStop');
  529. evStop.value = d ? d.opts.value : 0;
  530. $sl.trigger(evStop);
  531. }
  532. }
  533. }
  534. function _setSliderLayer($sl, layer) {
  535. $sl.slider('setValue', layer);
  536. var ev = $.Event('slide'); ev.value = layer; $sl.trigger(ev);
  537. $sl.find('.slider-handle').text(layer);
  538. }
  539. function _updatePlayBtn() {
  540. var btn = document.getElementById('bb-play-btn');
  541. if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
  542. }
  543. // Run after all scripts have loaded. init() (viewmodel + 3D canvas) runs
  544. // 200 ms later to let prettygcode.js finish its own synchronous setup first.
  545. function onDomReady() {
  546. setTimeout(function () {
  547. init();
  548. // If the viewer was opened with ?archive=<id>[&plate=<N>] or
  549. // ?library_file=<id>[&plate=<N>], load that source's gcode once
  550. // the viewmodel is ready.
  551. try {
  552. var params = new URLSearchParams(window.location.search);
  553. var archiveParam = params.get('archive');
  554. var libParam = params.get('library_file');
  555. var plateParam = params.get('plate');
  556. var plateId = (plateParam && /^[1-9][0-9]*$/.test(plateParam))
  557. ? parseInt(plateParam, 10)
  558. : undefined;
  559. if (archiveParam && /^[1-9][0-9]*$/.test(archiveParam)) {
  560. var archiveId = parseInt(archiveParam, 10);
  561. setTimeout(function () { loadArchiveById(archiveId, plateId); }, 50);
  562. } else if (libParam && /^[1-9][0-9]*$/.test(libParam)) {
  563. var libId = parseInt(libParam, 10);
  564. setTimeout(function () { loadLibraryFileById(libId, plateId); }, 50);
  565. }
  566. } catch (e) { /* URLSearchParams unsupported — skip */ }
  567. }, 200);
  568. }
  569. if (document.readyState === 'loading') {
  570. document.addEventListener('DOMContentLoaded', onDomReady);
  571. } else {
  572. onDomReady();
  573. }
  574. // -------------------------------------------------------------------------
  575. // Public API
  576. // -------------------------------------------------------------------------
  577. window.BambuddyPrettyGCode = {
  578. loadArchive: loadArchiveById,
  579. loadLibraryFile: loadLibraryFileById,
  580. getViewModel: function () { return viewModel; },
  581. play: startPlayback,
  582. stop: stopPlayback,
  583. };
  584. })();