mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 03:54:17 +02:00
Fix: 3D preview no longer freezes the page on complex multi-part 3MFs (#1412)
The browser-side 3MF parser in ModelViewer.tsx runs entirely on the main thread (JSZip extract + DOMParser + vertex/triangle iteration), and Bambu's external-component shape chains one of these per part. Multi-color parted statues from MakerWorld can spend tens of seconds in straight-line JS, during which the modal close button can't fire and the browser shows "page unresponsive". Add nextTick() yields at four hot spots: - every 20 000 vertex iterations inside parseMeshFromDoc - every 20 000 triangle iterations inside parseMeshFromDoc - the matching loops in parse3MF's direct-mesh path - once per top-level <object> iteration in parse3MF - once per <component> iteration in parse3MF This doesn't make parsing faster — it surrenders control to the browser between batches so the modal stays interactive. Proper Web Worker refactor is a tracked follow-up.
This commit is contained in:
@@ -5,6 +5,8 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
## [0.2.5b1] - Unreleased
|
||||
|
||||
### Fixed
|
||||
- **Library 3D preview: complex multi-part 3MFs no longer freeze the page (#1412, reported by @anthonyma94)** — Reporter opened the 3D preview on a multi-color parted MakerWorld statue ("Mecha Mewtwo No AMS Multi Color Parted Statue") and the whole Bambuddy UI locked up — modal close button unresponsive, had to kill the tab. Root cause was in `frontend/src/components/ModelViewer.tsx`: the 3MF parse runs entirely on the browser main thread (JSZip extract + DOMParser + `getElementsByTagName('vertex')` / `('triangle')` iteration + `mergeGeometries`), with no yield points between iterations. Bambu Studio's external-component shape (`<component p:path="..."/>` per part) compounds this — each component triggers another async file extract + DOM parse + vertex/triangle loop, all chained without surrendering control to the event loop between phases. For trivial models (the towel hook and Bambu scraper the reporter cited as working) the total wall-clock is short enough that the freeze isn't visible; for parted statues with dozens of components and high-poly meshes, the main thread is pegged for tens of seconds → browser shows "page unresponsive" and the close button can't fire. **Stopgap that shipped here** adds explicit `nextTick()` yields (`await new Promise(r => setTimeout(r, 0))`) at four hot spots: every 20 000 vertex iterations, every 20 000 triangle iterations, once per top-level `<object>` iteration, and once per `<component>` iteration. Parse wall-clock is unchanged — these yields don't make parsing faster, they just surrender the main thread back to the browser between batches so the modal can be closed, the page can scroll, and the loading spinner can actually render. Constants live next to the helper at the top of the file with a comment justifying the picked period (~5–10 ms of work per batch — fine-grained enough to keep frames flowing, coarse enough not to drown the loop in setTimeout dispatch overhead). The proper fix for this — moving 3MF parse + geometry build into a Web Worker so the main thread is never touched at all — is a tracked follow-up; this stopgap unblocks Anthony's reproduction case today without the worker refactor risk. The earlier close as `invalid` was a misdiagnosis (initial reading was that 3D preview only works on sliced files, which the reporter correctly disproved with a separate MakerWorld URL); reopened, fixed, lesson noted. **Tests:** 21 existing `ModelViewerModal.test.tsx` tests stay green — the yields are in `parseMeshFromDoc` and `parse3MF` which the tests mock around, and the `nextTick` helper has no observable side effects beyond timing. Frontend build clean.
|
||||
|
||||
- **Archives: timelapse auto-attach now works for VP-queue / dispatch prints (#1403 follow-up, reported by @pwostran)** — Bambuddy uses a snapshot-diff strategy to pick the right MP4 off the printer's SD card after a print (Bambu printers in LAN-only mode don't sync NTP, so file mtimes are unreliable — `_scan_for_timelapse_with_retries` snapshots existing video filenames at print start and looks for any NEW filename at completion). The baseline-capture call was inline at the bottom of `on_print_start`'s **new-archive branch only** — the **expected-archive branch** (which queue / VP-dispatched / reprinted jobs take, anything registered via `register_expected_print`) exited at its own `return` without ever snapshotting. So queue prints had `_timelapse_baselines[printer_id]` unset; the completion-time scan fell into its "take baseline now" fallback that snapshots the SD card *after* the new MP4 has already landed → the new file sits in the "baseline" set → no diff ever matches → auto-attach silently does nothing. The reporter's `bambuddy-support-20260518-185935.zip` shows the failure verbatim: `Using expected archive 3 for print (skipping duplicate)` at `18:41:10`, `Timelapse was active during print, scheduling auto-scan for archive 3` at `18:58:55`, and `[TIMELAPSE] Archive 3 has no printer, aborting` (a separate bug already fixed by the printer_id-assignment commit in this same train) — and `grep -i baseline` across both his bundles returns zero hits, confirming the snapshot never ran. Fix extracts the inline baseline-capture into `_capture_timelapse_baseline_at_start(printer, printer_id, logger)` and calls it from BOTH branches of `on_print_start` (mirroring the existing site in the new-archive branch with a matching call just before the expected-archive branch's `return`). Helper is best-effort with a `try / except Exception` wrapping `_list_timelapse_videos`, so a transient FTP failure at print-start logs `[TIMELAPSE] Failed to capture baseline at print start: …` and the print proceeds — the completion-time fallback still kicks in (with its known limitation), behaviour matching what the new-archive branch had all along. **Tests:** new `test_expected_archive_path_captures_timelapse_baseline` in the existing `test_print_start_assigns_printer_id_to_vp_archive.py` patches `_list_timelapse_videos` to return two pre-existing videos, runs `on_print_start` through the expected-archive branch, and asserts `_timelapse_baselines[1] == {"earlier_print_a.mp4", "earlier_print_b.mp4"}` — a future refactor that removes the call from one of the branches now fails CI. The existing 2 regressions (`test_expected_archive_path_assigns_printer_id_when_unset`, `test_expected_archive_path_preserves_existing_printer_id`) plus 50 adjacent expected-archive / layer-timelapse / archive-filtering tests still green. The fixture clearing `_expected_prints` etc also clears `_timelapse_baselines` now so test isolation holds.
|
||||
|
||||
## [0.2.4.1] - 2026-05-16
|
||||
|
||||
@@ -51,6 +51,22 @@ interface Parsed3MFData {
|
||||
plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
|
||||
}
|
||||
|
||||
// Yield to the browser event loop so the main thread can repaint, process
|
||||
// user input (especially the modal's close button), and avoid the
|
||||
// "page unresponsive" dialog while we crunch through large 3MFs in
|
||||
// straight-line JS. setTimeout(_, 0) is sufficient — we don't need rAF
|
||||
// here, the goal is just to surrender control so queued tasks run.
|
||||
function nextTick(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
// Yield once per N iterations of a hot loop. Picked so each batch is
|
||||
// ~5-10 ms of work on a typical desktop — fine-grained enough to keep
|
||||
// frames flowing, coarse enough not to drown the loop in setTimeout
|
||||
// dispatch overhead. Adjust if profiling shows otherwise.
|
||||
const YIELD_EVERY_N_VERTICES = 20000;
|
||||
const YIELD_EVERY_N_TRIANGLES = 20000;
|
||||
|
||||
// Parse 3MF transform - keep in 3MF coordinate space (Z-up)
|
||||
function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
|
||||
const matrix = new THREE.Matrix4();
|
||||
@@ -96,6 +112,9 @@ async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Pro
|
||||
parseFloat(v.getAttribute('y') || '0'),
|
||||
parseFloat(v.getAttribute('z') || '0')
|
||||
);
|
||||
if (k > 0 && k % YIELD_EVERY_N_VERTICES === 0) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
const triangleElements = meshEl.getElementsByTagName('triangle');
|
||||
@@ -106,6 +125,9 @@ async function parseMeshFromDoc(doc: Document, defaultExtruder: number = 0): Pro
|
||||
parseInt(t.getAttribute('v2') || '0'),
|
||||
parseInt(t.getAttribute('v3') || '0')
|
||||
);
|
||||
if (k > 0 && k % YIELD_EVERY_N_TRIANGLES === 0) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
if (vertices.length > 0 && triangles.length > 0) {
|
||||
@@ -326,6 +348,12 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
|
||||
// Parse objects - Bambu Studio uses components to reference external files
|
||||
const objectElements = mainDoc.getElementsByTagName('object');
|
||||
for (let i = 0; i < objectElements.length; i++) {
|
||||
// Yield once per top-level object so the modal stays interactive
|
||||
// throughout the parse (#1412). Inner vertex/triangle/component
|
||||
// loops yield on their own. See nextTick() comment near the top.
|
||||
if (i > 0) {
|
||||
await nextTick();
|
||||
}
|
||||
const objEl = objectElements[i];
|
||||
const objectId = objEl.getAttribute('id');
|
||||
if (!objectId) continue;
|
||||
@@ -356,6 +384,9 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
|
||||
parseFloat(v.getAttribute('y') || '0'),
|
||||
parseFloat(v.getAttribute('z') || '0')
|
||||
);
|
||||
if (k > 0 && k % YIELD_EVERY_N_VERTICES === 0) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
const triangleElements = meshEl.getElementsByTagName('triangle');
|
||||
@@ -366,6 +397,9 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
|
||||
parseInt(t.getAttribute('v2') || '0'),
|
||||
parseInt(t.getAttribute('v3') || '0')
|
||||
);
|
||||
if (k > 0 && k % YIELD_EVERY_N_TRIANGLES === 0) {
|
||||
await nextTick();
|
||||
}
|
||||
}
|
||||
|
||||
if (vertices.length > 0 && triangles.length > 0) {
|
||||
@@ -376,6 +410,12 @@ async function parse3MF(arrayBuffer: ArrayBuffer): Promise<Parsed3MFData> {
|
||||
// Check for component references (Bambu Studio style)
|
||||
const componentElements = objEl.getElementsByTagName('component');
|
||||
for (let j = 0; j < componentElements.length; j++) {
|
||||
// Yield before each component — each one triggers another async file
|
||||
// load + DOM parse + vertex/triangle iteration. Multi-color "parted"
|
||||
// statues from MakerWorld can have dozens of components; without
|
||||
// this yield the whole chain runs as one long synchronous burst
|
||||
// between awaits and freezes the modal close button (#1412).
|
||||
await nextTick();
|
||||
const compEl = componentElements[j];
|
||||
// p:path attribute contains the external file reference
|
||||
const extPath = compEl.getAttribute('p:path') || compEl.getAttributeNS('http://schemas.microsoft.com/3dmanufacturing/production/2015/06', 'path');
|
||||
|
||||
File diff suppressed because one or more lines are too long
+1
-1
@@ -26,7 +26,7 @@
|
||||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-Brndhbee.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BRJQNS0m.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-KYwGxnG9.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user