Files
bambuddy/gcode_viewer/js/bambuddy_adapter.js
maziggy 6deaa513af ● feat(slicer): server-side slicing via OrcaSlicer / Bambu Studio sidecar
Adds an optional slicer-api/ Compose stack and wires Bambuddy's File
  Manager, Archives, and MakerWorld pages to a new server-side Slice flow.
  Slicing runs as an in-memory background job (POST returns 202 + job_id,
  polled via GET /api/v1/slice-jobs/{id}) so a multi-minute slice no
  longer pins the modal; result lands as a new .gcode.3mf in the same
  folder (or new archive for archive sources) with the embedded
  thumbnail extracted.

  Backend
  - New services: slice_dispatch (in-memory dispatcher, 30min retention
    sweep) and slicer_api (HTTP bridge with 4xx/5xx/connection error
    split that drives the 3MF embedded-settings fallback retry path).
  - New schemas: SliceRequest, SliceResponse, SliceArchiveResponse,
    SliceJobEnqueueResponse.
  - New routes: POST /library/files/{id}/slice,
    POST /archives/{id}/slice, GET /api/v1/slice-jobs/{id} (gated on
    LIBRARY_READ since job IDs are sequential and the body leaks source
    filenames and result IDs).
  - AppSettings + env defaults: use_slicer_api, orcaslicer_api_url,
    bambu_studio_api_url. DB-stored values override env defaults.

  Frontend
  - New SliceModal handles preset gating; enqueues then closes
    immediately.
  - New SliceJobTrackerProvider polls active jobs at app level, surfaces
    a single toast per job (queued -> running -> completed / failed)
    and invalidates library/archives queries on terminal status.
  - Settings -> Workflow -> Slicer card: preferred slicer dropdown,
    Use Slicer API toggle, contextual sidecar URL field.
  - File Manager / Archives / MakerWorld get a Slice button gated on
    the Use Slicer API setting.
  - gcode-viewer adapter learns ?library_file=<id> so sliced library
    files preview inline.

  i18n
  - New slice.* and settings.{useSlicerApi,slicerCard,orcaslicerApiUrl,
    bambuStudioApiUrl,slicerApiUrlDescription,useSlicerApiDescription}
    + fileManager.noPermissionSlice keys across all 8 locales (en, de,
    fr, it, ja, pt-BR, zh-CN, zh-TW). English fully translated, German
    fully translated, the other six seeded with English fallbacks
    pending native translation.

  Tests
  - 10 backend integration tests in test_library_slice_api.py covering
    validation (404/400), happy-path enqueue, sidecar-down, 3MF
    embedded-settings fallback, STL no-fallback, and preset-error ->
    failed job paths.
  - New unit tests in test_slicer_api.py for the HTTP bridge.
  - 5 new SliceModal frontend tests covering preset gating, library +
    archive enqueue paths, error surface, and preset-load failure.
  - Existing SettingsPage tests adjusted: slicer dropdown asserts now
    switch to the Workflow tab first; added a beforeEach URL reset so
    one test's tab click doesn't bleed into sibling tests.

  Sidecar
  - New slicer-api/ folder is self-contained and optional. Two services
    (orca-slicer-api on 3003, bambu-studio-api on 3001 behind --profile
    bambu) build via Docker git-build-context from
    maziggy/orca-slicer-api@bambuddy/profile-resolver. The fork patches
    the OrcaSlicer CLI's profile compatibility quirks (inherits-chain
    resolver, from:User -> system rewrite, '# ' clone-prefix strip,
    sentinel-value strip) empirically required to slice real GUI
    exports without segfaulting the CLI.

  Docs
  - CHANGELOG entry under [0.2.4b1] - Unreleased Added.
  - README File Manager bullet for the new server-side Slice button.
  - bambuddy-website features.html: new card under "Configurable Slicer".
  - bambuddy-wiki: new page features/slicer-api.md + nav entry +
    features index card.

  Notes
  - Opt-in: with Use Slicer API off, the existing "open in desktop
    slicer via URI" flow is the default and unchanged.
  - 3MF inputs that segfault the CLI on --load-settings transparently
    retry with embedded settings; the resulting job carries
    used_embedded_settings: true.
  - Sliced files always export as .gcode.3mf so File Manager picks up
    the embedded thumbnail; file_type is set to "gcode" (blue badge).
2026-04-27 15:28:37 +02:00

649 lines
27 KiB
JavaScript
Raw Permalink 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.
/**
* bambuddy_adapter.js
* Bridges OctoPrint-PrettyGCode to Bambuddy's API.
*
* Load this BEFORE prettygcode.js. It provides:
* - OCTOPRINT_VIEWMODELS shim
* - Minimal KnockoutJS observable shim (ko.observable)
* - fetch() + XHR interceptors for path rewriting
* - Bambuddy WebSocket → fromCurrentData bridge
* - File picker backed by Bambuddy's library API
* - Settings load/save via plugin settings endpoint
*
* What works:
* - Full 3D GCode visualisation
* - Dark mode and all dat.GUI settings
* - File selection from Bambuddy's file library
* - Print progress highlight (% based)
* - Auto-load currently printing file
*
* What doesn't work (Bambu hardware limitation):
* - Live nozzle animation during printing — Bambu printers do not expose
* GCode serial echo logs (Send: G1 X...), so PrintHeadSimulator has no input.
*/
(function () {
'use strict';
const API_BASE = '/api/v1';
const VIEWER_BASE = '/gcode-viewer'; // static assets now served from here
// -------------------------------------------------------------------------
// Auth helper
// -------------------------------------------------------------------------
function authHeaders() {
// sessionStorage is used when the user opts out of "remember me";
// fall back to localStorage for persistent sessions.
const token = sessionStorage.getItem('auth_token') ?? localStorage.getItem('auth_token');
return token ? { Authorization: 'Bearer ' + token } : {};
}
// When auth is enabled and the user has no valid token, every API call
// returns 401 and the viewer chrome stays on screen showing empty state.
// Intercept the first 401 and hand control back to the SPA, which owns
// the login flow and will redirect to /login when appropriate.
let _authRedirectFired = false;
function apiFetch(path, opts) {
return fetch(API_BASE + path, {
...opts,
headers: { ...authHeaders(), ...(opts && opts.headers) },
cache: 'no-store',
}).then((response) => {
if (response.status === 401 && !_authRedirectFired) {
_authRedirectFired = true;
try {
sessionStorage.removeItem('auth_token');
localStorage.removeItem('auth_token');
} catch (e) { /* storage unavailable */ }
window.top.location.replace('/');
}
return response;
});
}
// -------------------------------------------------------------------------
// 1. Minimal KnockoutJS shim (ko.observable / ko.computed)
// -------------------------------------------------------------------------
window.ko = {
observable: function (initial) {
var _val = initial;
var _subs = [];
var obs = function (newVal) {
if (arguments.length > 0) {
_val = newVal;
_subs.forEach(function (cb) { try { cb(newVal); } catch (e) {} });
}
return _val;
};
obs.subscribe = function (cb) {
_subs.push(cb);
return { dispose: function () { _subs = _subs.filter(function (s) { return s !== cb; }); } };
};
obs.peek = function () { return _val; };
return obs;
},
computed: function (fn) {
var obs = window.ko.observable(null);
try { obs(fn()); } catch (e) {}
return obs;
},
pureComputed: function (fn) { return window.ko.computed(fn); },
mapping: { fromJS: function (obj) { return obj; } },
};
// -------------------------------------------------------------------------
// 2. OCTOPRINT_VIEWMODELS registration shim
// -------------------------------------------------------------------------
window.OCTOPRINT_VIEWMODELS = [];
// -------------------------------------------------------------------------
// 3. Fake OctoPrint settings / printer profile / login viewmodels
// -------------------------------------------------------------------------
var fakeSettings = {
webcam: {
streamUrl: ko.observable(''),
flipH: ko.observable(false),
flipV: ko.observable(false),
rotate90: ko.observable(false),
},
plugins: {
prettygcode: {
darkMode: ko.observable(false),
},
},
};
// Bed sizes for common Bambu models (mm)
// Fallback bed size used until loadArchiveById() fetches the archive's
// actual build_volume from /api/v1/archives/{id}/capabilities.
var DEFAULT_BED = { width: 256, depth: 256, height: 256 };
var currentBed = Object.assign({}, DEFAULT_BED);
function makeFakeProfileData(bed) {
return {
volume: {
width: ko.observable(bed.width),
depth: ko.observable(bed.depth),
height: ko.observable(bed.height),
origin: ko.observable('lowerleft'),
formFactor: ko.observable('rectangular'),
// Make custom_box a function so prettygcode.js uses width()/depth()/height()
custom_box: function () { return false; },
},
};
}
var fakePrinterProfiles = {
currentProfileData: ko.observable(makeFakeProfileData(currentBed)),
};
var fakeLoginState = {
isUser: ko.observable(true),
isAdmin: ko.observable(false),
};
var fakeControl = {};
// -------------------------------------------------------------------------
// 4. fetch() interceptor — rewrite OctoPrint paths to Bambuddy
// -------------------------------------------------------------------------
var _originalFetch = window.fetch.bind(window);
window.fetch = function (resource, init) {
var url = (typeof resource === 'string') ? resource
: (resource && resource.url) ? resource.url
: null;
if (url) {
// Normalize: strip scheme+host so regexes work on the path regardless
// of whether the browser resolved a relative URL to absolute.
var path = url.replace(/^https?:\/\/[^\/]+/, '');
// Also strip the viewer's own path prefix — the browser resolves relative URLs
// like 'downloads/files/local/...' to '/gcode-viewer/downloads/...' because
// the page is served from /gcode-viewer/. The regexes below expect bare paths.
path = path.replace(/^\/gcode-viewer(?=\/|$)/, '');
var newPath = path;
// OctoPrint file download → Bambuddy library download
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_file_(\d+)$/,
API_BASE + '/library/files/$1/download'
);
// OctoPrint file download → Bambuddy archive gcode (specific plate)
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)_plate(\d+)$/,
API_BASE + '/archives/$1/gcode?plate=$2'
);
// OctoPrint file download → Bambuddy archive gcode (first plate)
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_archive_(\d+)$/,
API_BASE + '/archives/$1/gcode'
);
// OctoPrint file download → Bambuddy library file gcode
// (sliced LibraryFile — extracts embedded gcode from .gcode.3mf
// or returns plain .gcode). Plate is ignored upstream for now.
newPath = newPath.replace(
/^\/?downloads\/files\/local\/__bambuddy_libgcode_(\d+)(?:_plate\d+)?$/,
API_BASE + '/library/files/$1/gcode'
);
// OctoPrint plugin static assets → gcode-viewer static files
newPath = newPath.replace(
/^\/?plugin\/prettygcode\/static\//,
VIEWER_BASE + '/'
);
if (newPath !== path) {
url = newPath;
resource = url; // always pass as string after rewriting
}
// Inject auth header for all Bambuddy API calls
if (url.startsWith(API_BASE)) {
var hdrs = authHeaders();
init = init || {};
init.headers = Object.assign({}, hdrs, init.headers || {});
}
}
var promise = _originalFetch(resource, init);
// Tee GCode downloads to build the layer map for sync + nozzle animation
if (url && (url.match(/\/library\/files\/\d+\/download/) || url.match(/\/archives\/\d+\/gcode/))) {
promise = promise.then(function (response) {
var clone = response.clone();
clone.text().then(function (text) {
gcodeLayerMap = parseGcodeLayerMap(text);
lastFedLayer = -1;
console.log('[PrettyGCode] Parsed ' + gcodeLayerMap.layerOffsets.length +
' layers for sync (' + Math.round(gcodeLayerMap.totalBytes / 1024) + ' KB)');
}).catch(function (e) {
console.warn('[PrettyGCode] GCode layer parse failed:', e);
});
return response;
});
}
return promise;
};
// -------------------------------------------------------------------------
// 5. XHR interceptor — rewrite OctoPrint paths (used by THREE.OBJLoader etc.)
// -------------------------------------------------------------------------
var _origXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
if (typeof url === 'string') {
// Strip host if absolute, then rewrite OctoPrint static asset paths
var path = url.replace(/^https?:\/\/[^\/]+/, '');
path = path.replace(/^\/?plugin\/prettygcode\/static\//, VIEWER_BASE + '/');
url = path;
}
var args = Array.prototype.slice.call(arguments);
args[1] = url;
return _origXHROpen.apply(this, args);
};
// -------------------------------------------------------------------------
// 6. GCode layer parser
//
// Builds a layer map from the raw GCode text so we can:
// a) Map layer_num → byte offset in file (drives prettygcode's filepos sync
// and layer highlight, same as if OctoPrint were reporting filepos)
// b) Extract a set of G0/G1 commands per layer to feed the PrintHeadSimulator
// as synthetic "Send: G1 X... Y... Z..." entries, animating the nozzle model.
//
// Layer detection mirrors prettygcode.js: a new layer starts on the first extrusion
// at a Z position we haven't extruded at before.
// -------------------------------------------------------------------------
function parseGcodeLayerMap(text) {
var lines = text.split('\n');
var layerOffsets = []; // layerOffsets[i] = byte pos in file where layer i starts
var layerCmds = []; // layerCmds[i] = array of ' G1 X... Y... Z...' strings
var byteOffset = 0;
var x = 0, y = 0, z = 0, e = 0;
var relative = false, relativeE = false;
var currentLayerZ = null;
var curCmds = [];
for (var i = 0; i < lines.length; i++) {
var raw = lines[i];
// +1 for the \n that was consumed by split
var lineBytes = raw.length + 1;
var cmd = raw.replace(/;.*$/, '').trim();
if (!cmd) { byteOffset += lineBytes; continue; }
var parts = cmd.split(/\s+/);
var g = parts[0].toUpperCase();
if (g === 'G90') { relative = false; relativeE = false; }
else if (g === 'G91') { relative = true; relativeE = true; }
else if (g === 'M82') { relativeE = false; }
else if (g === 'M83') { relativeE = true; }
else if (g === 'G92') {
// coordinate reset
for (var p = 1; p < parts.length; p++) {
var k0 = parts[p][0].toUpperCase();
var v0 = parseFloat(parts[p].slice(1));
if (!isNaN(v0)) {
if (k0 === 'X') x = v0;
else if (k0 === 'Y') y = v0;
else if (k0 === 'Z') z = v0;
else if (k0 === 'E') e = v0;
}
}
} else if (g === 'G0' || g === 'G1') {
var nx = x, ny = y, nz = z, ne = e;
var hasE = false;
for (var p = 1; p < parts.length; p++) {
if (!parts[p]) continue;
var k1 = parts[p][0].toUpperCase();
var v1 = parseFloat(parts[p].slice(1));
if (isNaN(v1)) continue;
if (k1 === 'X') nx = relative ? x + v1 : v1;
else if (k1 === 'Y') ny = relative ? y + v1 : v1;
else if (k1 === 'Z') nz = relative ? z + v1 : v1;
else if (k1 === 'E') { ne = relativeE ? e + v1 : v1; hasE = true; }
}
// New layer: first extrusion at a new Z (same logic as prettygcode.js)
if (hasE && ne > e && nz !== currentLayerZ) {
currentLayerZ = nz;
if (curCmds.length > 0) layerCmds.push(curCmds);
else if (layerOffsets.length > 0) layerCmds.push([]); // gap layer
curCmds = [];
layerOffsets.push(byteOffset);
}
// Record movement commands for nozzle sim (keep arrays small — max 500/layer)
if ((hasE || nz !== z) && curCmds.length < 500) {
curCmds.push(' G1 X' + nx.toFixed(3) +
' Y' + ny.toFixed(3) +
' Z' + nz.toFixed(3));
}
x = nx; y = ny; z = nz; e = ne;
}
byteOffset += lineBytes;
}
if (curCmds.length > 0) layerCmds.push(curCmds);
return {
layerOffsets: layerOffsets,
layerCmds: layerCmds,
totalBytes: byteOffset,
};
}
// -------------------------------------------------------------------------
// 8. State
// -------------------------------------------------------------------------
var viewModel = null;
var currentFileId = null;
var currentFilename = null;
var currentFileDate = 0; // stable epoch — only changes when a new file is loaded
var gcodeLayerMap = null; // parsed layer data: {layerOffsets, layerCmds, totalBytes}
var lastFedLayer = -1; // last layer_num whose commands we fed to printHeadSim
// The viewer is scoped to previewing a specific archive (/gcode-viewer?archive=<id>).
// It no longer observes live printer state, so the WebSocket connection, the
// printer selector, auto-load-currently-printing, and library file picker are all
// intentionally absent. Bed size is derived from the archive's sliced_for_model.
function updateFilenameDisplay(filename) {
var el = document.getElementById('bb-current-file');
if (el) el.textContent = filename || '— no file loaded —';
}
// -------------------------------------------------------------------------
// 10. Archive loader — invoked via /gcode-viewer/?archive=<id>
// -------------------------------------------------------------------------
function loadArchiveById(archiveId, plate) {
// Pretygcode downloads /downloads/files/local/__bambuddy_archive_<id>(_plate<N>)
// and the fetch intercept rewrites it to /api/v1/archives/<id>/gcode[?plate=N].
var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
currentFileId = 'archive_' + archiveId + plateSuffix;
currentFilename = 'Archive #' + archiveId + (plateSuffix ? (' (plate ' + plate + ')') : '');
currentFileDate = Date.now();
gcodeLayerMap = null;
lastFedLayer = -1;
stopPlayback(true);
updateFilenameDisplay(currentFilename);
var playBtn = document.getElementById('bb-play-btn');
if (playBtn) playBtn.disabled = false;
if (viewModel && viewModel.fromCurrentData) {
viewModel.fromCurrentData({
job: {
file: {
path: '__bambuddy_archive_' + archiveId + plateSuffix,
date: currentFileDate,
},
estimatedPrintTime: null,
},
state: { text: 'Operational', flags: { printing: false } },
progress: { filepos: null, completion: 0 },
currentZ: null,
logs: [],
});
}
// Fetch metadata (for the filename display) and capabilities (for the
// bed size) in parallel. Capabilities extracts the actual build_volume
// from the 3MF's slicer config (printable_area / printable_height), so
// the bed matches whatever hardware the archive was sliced for — no
// hardcoded per-model map, correct for H2D (350×320×325), H-family
// machines, and any future model.
apiFetch('/archives/' + archiveId, {})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (meta) {
if (meta && (meta.print_name || meta.filename)) {
currentFilename = (meta.print_name || meta.filename) +
(plateSuffix ? (' (plate ' + plate + ')') : '');
updateFilenameDisplay(currentFilename);
}
})
.catch(function () { /* best-effort — filename stays "Archive #N" */ });
apiFetch('/archives/' + archiveId + '/capabilities', {})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (caps) {
if (!caps || !caps.build_volume) return;
var bv = caps.build_volume;
if (bv.x > 0 && bv.y > 0 && bv.z > 0) {
currentBed = { width: bv.x, depth: bv.y, height: bv.z };
fakePrinterProfiles.currentProfileData(makeFakeProfileData(currentBed));
}
})
.catch(function () { /* best-effort — default bed stays */ });
}
// -------------------------------------------------------------------------
// 10b. Library file loader — invoked via /gcode-viewer/?library_file=<id>
// -------------------------------------------------------------------------
function loadLibraryFileById(fileId, plate) {
// Mirror loadArchiveById, but use the library gcode endpoint. The
// /library/files/<id>/gcode endpoint extracts the embedded gcode
// from a .gcode.3mf or returns a plain .gcode body. It does not
// accept a plate selector today — multi-plate library files would
// need an upstream change. The plate suffix here is preserved in
// currentFilename for display only.
var plateSuffix = (typeof plate === 'number' && plate >= 1) ? ('_plate' + plate) : '';
currentFileId = 'libfile_' + fileId + plateSuffix;
currentFilename = 'Library file #' + fileId + (plateSuffix ? (' (plate ' + plate + ')') : '');
currentFileDate = Date.now();
gcodeLayerMap = null;
lastFedLayer = -1;
stopPlayback(true);
updateFilenameDisplay(currentFilename);
var playBtn = document.getElementById('bb-play-btn');
if (playBtn) playBtn.disabled = false;
if (viewModel && viewModel.fromCurrentData) {
viewModel.fromCurrentData({
job: {
file: {
path: '__bambuddy_libgcode_' + fileId + plateSuffix,
date: currentFileDate,
},
estimatedPrintTime: null,
},
state: { text: 'Operational', flags: { printing: false } },
progress: { filepos: null, completion: 0 },
currentZ: null,
logs: [],
});
}
// Fetch metadata for the filename display. There is no
// /library/files/<id>/capabilities endpoint, so the bed stays at
// whatever the default fakePrinterProfile is set to.
apiFetch('/library/files/' + fileId, {})
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (meta) {
if (meta && (meta.print_name || meta.filename)) {
currentFilename = (meta.print_name || meta.filename) +
(plateSuffix ? (' (plate ' + plate + ')') : '');
updateFilenameDisplay(currentFilename);
}
})
.catch(function () { /* best-effort — filename stays "Library file #N" */ });
}
// -------------------------------------------------------------------------
// 11. Initialise after DOM + scripts are ready
// -------------------------------------------------------------------------
function init() {
// Find the ViewModel registration that prettygcode.js pushed
var reg = null;
for (var i = 0; i < window.OCTOPRINT_VIEWMODELS.length; i++) {
if (window.OCTOPRINT_VIEWMODELS[i].construct) {
reg = window.OCTOPRINT_VIEWMODELS[i];
break;
}
}
if (!reg) {
console.error('[PrettyGCode] No ViewModel found in OCTOPRINT_VIEWMODELS');
return;
}
try {
viewModel = new reg.construct([
fakeSettings,
fakeLoginState,
fakePrinterProfiles,
fakeControl,
]);
} catch (e) {
console.error('[PrettyGCode] ViewModel constructor failed:', e);
return;
}
if (viewModel.onAfterBinding) {
try { viewModel.onAfterBinding(); } catch (e) {}
}
// Trigger tab activation — this calls onTabChange which initialises the Three.js scene
if (viewModel.onTabChange) {
try { viewModel.onTabChange('#tab_plugin_prettygcode', ''); } catch (e) {
console.error('[PrettyGCode] onTabChange failed:', e);
}
}
console.log('[PrettyGCode] Bambuddy adapter initialised');
// Wire up playback controls
var playBtn = document.getElementById('bb-play-btn');
var speedSel = document.getElementById('bb-play-speed');
if (playBtn) {
playBtn.addEventListener('click', function () {
if (isPlaying) stopPlayback();
else startPlayback();
});
}
if (speedSel) {
speedSel.addEventListener('change', function () {
layersPerTick = parseInt(speedSel.value, 10) || 1;
// Restart if already playing so speed takes effect immediately
if (isPlaying) { stopPlayback(); startPlayback(); }
});
}
}
// -------------------------------------------------------------------------
// 14. Playback engine
// -------------------------------------------------------------------------
var isPlaying = false;
var playInterval = null;
var layersPerTick = 1; // layers advanced per 50 ms tick
var TICK_MS = 50; // ~20 fps
function getSlider() { return $('#myslider-vertical'); }
function startPlayback() {
var $sl = getSlider();
if (!$sl.length) return;
var data = $sl.data('_pgslider');
if (!data) return;
var max = data.opts.max || 0;
if (max === 0) return;
// Restart from beginning if already at the end
var cur = data.opts.value || 0;
if (cur >= max) cur = 0;
// Suppress live-print sync while playing
var evStart = $.Event('slideStart'); evStart.value = cur; $sl.trigger(evStart);
_setSliderLayer($sl, cur);
isPlaying = true;
_updatePlayBtn();
playInterval = setInterval(function () {
var d = getSlider().data('_pgslider');
if (!d) { stopPlayback(); return; }
var next = (d.opts.value || 0) + layersPerTick;
if (next >= d.opts.max) {
next = d.opts.max;
_setSliderLayer(getSlider(), next);
stopPlayback(/* skipEvStop */ false);
return;
}
_setSliderLayer(getSlider(), next);
}, TICK_MS);
}
function stopPlayback(skipEvStop) {
if (playInterval) { clearInterval(playInterval); playInterval = null; }
isPlaying = false;
_updatePlayBtn();
if (!skipEvStop) {
var $sl = getSlider();
if ($sl.length) {
var d = $sl.data('_pgslider');
var evStop = $.Event('slideStop');
evStop.value = d ? d.opts.value : 0;
$sl.trigger(evStop);
}
}
}
function _setSliderLayer($sl, layer) {
$sl.slider('setValue', layer);
var ev = $.Event('slide'); ev.value = layer; $sl.trigger(ev);
$sl.find('.slider-handle').text(layer);
}
function _updatePlayBtn() {
var btn = document.getElementById('bb-play-btn');
if (btn) btn.textContent = isPlaying ? '⏸' : '▶';
}
// Run after all scripts have loaded. init() (viewmodel + 3D canvas) runs
// 200 ms later to let prettygcode.js finish its own synchronous setup first.
function onDomReady() {
setTimeout(function () {
init();
// If the viewer was opened with ?archive=<id>[&plate=<N>] or
// ?library_file=<id>[&plate=<N>], load that source's gcode once
// the viewmodel is ready.
try {
var params = new URLSearchParams(window.location.search);
var archiveParam = params.get('archive');
var libParam = params.get('library_file');
var plateParam = params.get('plate');
var plateId = (plateParam && /^[1-9][0-9]*$/.test(plateParam))
? parseInt(plateParam, 10)
: undefined;
if (archiveParam && /^[1-9][0-9]*$/.test(archiveParam)) {
var archiveId = parseInt(archiveParam, 10);
setTimeout(function () { loadArchiveById(archiveId, plateId); }, 50);
} else if (libParam && /^[1-9][0-9]*$/.test(libParam)) {
var libId = parseInt(libParam, 10);
setTimeout(function () { loadLibraryFileById(libId, plateId); }, 50);
}
} catch (e) { /* URLSearchParams unsupported — skip */ }
}, 200);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', onDomReady);
} else {
onDomReady();
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
window.BambuddyPrettyGCode = {
loadArchive: loadArchiveById,
loadLibraryFile: loadLibraryFileById,
getViewModel: function () { return viewModel; },
play: startPlayback,
stop: stopPlayback,
};
})();