diff --git a/README.md b/README.md index 23a9ac41..4933396d 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,26 @@ This is a temporary workaround. We will remove this change after Bambu Lab fixes **Acknowledgements**: Thanks to alexbreinig for pointing out this issue and capsel22 for reporting it in the BambuStudio issues. For more details, see: https://github.com/bambulab/BambuStudio/issues/8801 +### Missing Presets for Some Nozzle / Extruder Options + +**Issue**: Bambu Lab printers that offer more than one extruder/nozzle option (for example the multi-nozzle machines such as X2D and the H2 series) store a separate set of values for each option inside a single filament preset. The available options are: + +- `Direct Drive Standard` +- `Direct Drive High Flow` +- `Bowden Standard` +- `Bowden High Flow` + +For some materials we have only tuned and authored values for one option (typically `Direct Drive Standard`). The remaining options are left empty (`nil`) in the preset, which means the auxiliary / additional nozzle has **no tuned values** for that material. This includes the auxiliary (Bowden) nozzle on dual-nozzle machines. + +**What we do about it**: When you download presets from the [download page](https://presets.polymaker.com), the site checks each selected preset. If any of them are missing values for one or more nozzle/extruder options, a warning appears before the download starts. The warning lists exactly which material and which options have no preset, for example: + +> We didn't make presets for these variants: +> - PolyLite PLA — Bambu Lab X2D: Direct Drive High Flow, Bowden Standard, Bowden High Flow + +You can still continue the download — the listed nozzle options simply won't have tuned values, so they fall back to the slicer's generic settings. Printing on the option that **does** have a preset (usually the main Direct Drive nozzle) is unaffected. + +We are progressively adding presets for the remaining nozzle options. If a material you need is missing values for a nozzle option you use, please let us know. + ## 🌐 Download Presets **Visit our download page:** [https://presets.polymaker.com](https://presets.polymaker.com) diff --git a/app.js b/app.js index 077ccbdf..90bf6fcb 100644 --- a/app.js +++ b/app.js @@ -918,10 +918,38 @@ function init() { } // Download multiple presets as ZIP - function downloadSelectedPresets() { + // Aggregate the selected presets into a de-duplicated list of + // { material, model, variants } for those missing extruder variants. + function collectMissingVariantWarnings(presets) { + var seen = {}; + var items = []; + for (var key in presets) { + if (!Object.prototype.hasOwnProperty.call(presets, key)) continue; + var p = presets[key]; + var missing = p && p.missingExtruderVariants; + if (!missing || !missing.length) continue; + var dedupKey = (p.material || '') + '||' + (p.model || '') + '||' + missing.join(','); + if (seen[dedupKey]) continue; + seen[dedupKey] = true; + items.push({ material: p.material || '', model: p.model || '', variants: missing.slice() }); + } + return items; + } + + function downloadSelectedPresets(skipVariantWarning) { var presetIds = Object.keys(selectedPresets); if (presetIds.length === 0) return; + if (!skipVariantWarning) { + var variantWarnings = collectMissingVariantWarnings(selectedPresets); + if (variantWarnings.length) { + showMissingVariantWarning(variantWarnings, function () { + downloadSelectedPresets(true); + }); + return; + } + } + // Show loading state if (downloadSelectedBtn) { downloadSelectedBtn.disabled = true; @@ -1040,7 +1068,16 @@ function init() { }); } - function downloadSelectedBundle() { + function downloadSelectedBundle(skipVariantWarning) { + if (!skipVariantWarning) { + var variantWarnings = collectMissingVariantWarnings(selectedPresets); + if (variantWarnings.length) { + showMissingVariantWarning(variantWarnings, function () { + downloadSelectedBundle(true); + }); + return; + } + } // Filter for BambuStudio presets only var bambuPresets = []; for (var key in selectedPresets) { @@ -1392,12 +1429,34 @@ function init() { showBambuRestartWarning(action, function () {}); } + // For a single-row download: read the row's preset payload and, if it has + // missing extruder variants, show the warning modal before proceeding. + function withMissingVariantWarning(linkEl, action) { + var row = linkEl ? linkEl.closest('tr') : null; + var label = row ? row.querySelector('.preset-checkbox') : null; + if (label) { + try { + var d = JSON.parse(label.getAttribute('data-preset-data')); + if (d && d.missingExtruderVariants && d.missingExtruderVariants.length) { + showMissingVariantWarning( + [{ material: d.material || '', model: d.model || '', variants: d.missingExtruderVariants.slice() }], + action + ); + return; + } + } catch (e) { /* fall through to action */ } + } + action(); + } + if (downloadSelectedBtn) { - downloadSelectedBtn.addEventListener('click', downloadSelectedPresets); + // Wrap so the click Event isn't passed as skipVariantWarning (which would + // bypass the missing-variant guard); the internal re-invoke passes true. + downloadSelectedBtn.addEventListener('click', function () { downloadSelectedPresets(); }); } if (downloadBundleBtn) { - downloadBundleBtn.addEventListener('click', downloadSelectedBundle); + downloadBundleBtn.addEventListener('click', function () { downloadSelectedBundle(); }); } // Handle strict checkbox change @@ -1552,7 +1611,7 @@ function init() { var filename = displayFilename(p.filename, p.slicer); var presetId = p.path || (p.material + '-' + p.brand + '-' + p.model + '-' + p.slicer); var isChecked = selectedPresets[presetId] ? ' checked' : ''; - var presetData = JSON.stringify({ url: url, filename: filename, slicer: p.slicer, path: p.path, material: p.material, model: p.model, brand: p.brand }); + var presetData = JSON.stringify({ url: url, filename: filename, slicer: p.slicer, path: p.path, material: p.material, model: p.model, brand: p.brand, missingExtruderVariants: p.missingExtruderVariants || [] }); var checkboxHtml = ''; var printerBrand = getPrinterBrand(p.compatiblePrinters, p.brand); var compatiblePrintersList = formatCompatiblePrinters(p.compatiblePrinters); @@ -1737,7 +1796,9 @@ function init() { }); } - withBambuRestartWarning(doDownloadBambuJson); + withMissingVariantWarning(bambuJsonLink, function () { + withBambuRestartWarning(doDownloadBambuJson); + }); return; } @@ -1755,31 +1816,35 @@ function init() { return; } - // Fetch the preset JSON to get filament_vendor - fetch(url, { mode: 'cors' }) - .then(function (r) { - if (!r.ok) throw new Error('Failed to fetch preset: ' + r.statusText); - return r.json(); - }) - .then(function (data) { - if (!data || !data.compatible_printers) { - console.warn('Preset data missing compatible_printers:', data); - } - var preset = { - path: url, - filename: filename, - material: data.name || material, - model: model, - slicer: 'BambuStudio', - filament_vendor: data.filament_vendor || ['Polymaker'], - presetData: data - }; - downloadAsBbsflmt([preset]); - }) - .catch(function (err) { - console.error('Error downloading bundle:', err); - alert(t('alert.error.preset', { msg: err.message })); - }); + function doDownloadBundle() { + // Fetch the preset JSON to get filament_vendor + fetch(url, { mode: 'cors' }) + .then(function (r) { + if (!r.ok) throw new Error('Failed to fetch preset: ' + r.statusText); + return r.json(); + }) + .then(function (data) { + if (!data || !data.compatible_printers) { + console.warn('Preset data missing compatible_printers:', data); + } + var preset = { + path: url, + filename: filename, + material: data.name || material, + model: model, + slicer: 'BambuStudio', + filament_vendor: data.filament_vendor || ['Polymaker'], + presetData: data + }; + downloadAsBbsflmt([preset]); + }) + .catch(function (err) { + console.error('Error downloading bundle:', err); + alert(t('alert.error.preset', { msg: err.message })); + }); + } + + withMissingVariantWarning(bundleLink, doDownloadBundle); return; } }); @@ -1968,6 +2033,62 @@ function showBambuRestartWarning(onConfirm, onCancel) { document.body.style.overflow = 'hidden'; } +var missingVariantModalState = { + onAck: null +}; + +function showMissingVariantWarning(items, onAck) { + var modal = document.getElementById('missing-variant-modal'); + if (!modal || !items || !items.length) { + if (onAck) onAck(); + return; + } + + var listEl = document.getElementById('missing-variant-list'); + if (listEl) { + var html = ''; + for (var i = 0; i < items.length; i++) { + html += '
  • ' + escapeHtml(items[i].material) + ' — ' + escapeHtml(items[i].model) + + ': ' + escapeHtml(items[i].variants.join(', ')) + '
  • '; + } + listEl.innerHTML = html; + } + + missingVariantModalState.onAck = onAck; + modal.setAttribute('aria-hidden', 'false'); + modal.classList.add('is-open'); + document.body.style.overflow = 'hidden'; +} + +function initMissingVariantModal() { + var modal = document.getElementById('missing-variant-modal'); + if (!modal) return; + + var closeBtn = document.getElementById('missing-variant-modal-close'); + var ackBtn = document.getElementById('missing-variant-ack'); + var overlay = modal.querySelector('.modal-overlay'); + + // Acknowledge-only: every dismissal path proceeds with the download. + function ack() { + modal.setAttribute('aria-hidden', 'true'); + modal.classList.remove('is-open'); + document.body.style.overflow = ''; + var cb = missingVariantModalState.onAck; + missingVariantModalState.onAck = null; + if (cb) cb(); + } + + if (closeBtn) closeBtn.addEventListener('click', ack); + if (ackBtn) ackBtn.addEventListener('click', ack); + if (overlay) overlay.addEventListener('click', ack); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && modal.classList.contains('is-open')) { + ack(); + } + }); +} + /** * Format printer model name for display * @param {string} model - Raw model name (e.g., "A1M", "A1") @@ -2243,4 +2364,5 @@ init(); initModal(); initDuplicateModal(); initBambuRestartModal(); +initMissingVariantModal(); initAccordion(); diff --git a/docs/superpowers/plans/2026-06-16-missing-extruder-variant-warning.md b/docs/superpowers/plans/2026-06-16-missing-extruder-variant-warning.md new file mode 100644 index 00000000..8bd36b7f --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-missing-extruder-variant-warning.md @@ -0,0 +1,748 @@ +# Missing Extruder-Variant Download Warning Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Warn users (inline badge + acknowledge-only modal at download) when selected Bambu presets have nozzle/extruder variants with no tuned values (`nozzle_temperature` column is `"nil"`). + +**Architecture:** Detection runs at build time in `scripts/generate-index-json.mjs`, recording a `missingExtruderVariants` string array per affected preset into `index.json`. The front end (`app.js`) reads that field: it renders a warning badge on affected preset rows, carries the field into the per-preset selection payload, and shows a single aggregated modal at the top of the two bulk download functions before the download proceeds. + +**Tech Stack:** Vanilla ES modules (Node `node:test` for tests), plain browser JS (`app.js`), `i18n.js` translation map, static HTML/CSS. + +**Scope note:** Per the approved spec, the modal hooks only the two *bulk* download paths (`downloadSelectedPresets`, `downloadSelectedBundle`). Per-row single-file download buttons are intentionally NOT modal-gated — the inline badge already sits on each affected row. This is a deliberate non-goal, not an omission. + +--- + +## File Structure + +- `scripts/generate-index-json.mjs` — add `extractMissingExtruderVariants()` pure helper + wire its result into each preset object. +- `scripts/generate-index-json.test.mjs` — unit tests for the detection rule (inline copy of the helper, matching the file's existing convention). +- `scripts/missing-variant-warning.test.mjs` — NEW: unit tests for the `collectMissingVariantWarnings()` selection-aggregation helper (mirrors the logic in `app.js`, matching the `app-filter.test.mjs` convention). +- `index.json` — regenerated; affected presets gain `missingExtruderVariants`. +- `index.html` — add the `missing-variant-modal` dialog markup. +- `i18n.js` — add `en` + `zh` keys for the modal and badge. +- `style.css` — add badge + modal-list styling. +- `app.js` — carry the field into the selection payload, render badges, add the aggregation helper + modal show/init functions, and guard the two bulk download functions. + +--- + +## Task 1: Build-time detection helper + +**Files:** +- Modify: `scripts/generate-index-json.mjs` (add helper near `extractCompatiblePrinters`, ~line 84; wire into the preset loop ~line 154-179) +- Test: `scripts/generate-index-json.test.mjs` + +- [ ] **Step 1: Write the failing tests** + +Add this `describe` block inside `scripts/generate-index-json.test.mjs`, immediately before the final closing `});` of the top-level `describe('Preset Files', ...)` block: + +```javascript + describe('extractMissingExtruderVariants function', () => { + // Mirrors the helper in generate-index-json.mjs + function extractMissingExtruderVariants(presetData) { + if (!presetData) return []; + const variants = presetData.filament_extruder_variant; + const temps = presetData.nozzle_temperature; + if (!Array.isArray(variants) || variants.length <= 1) return []; + if (!Array.isArray(temps) || temps.length !== variants.length) return []; + const missing = []; + for (let i = 0; i < variants.length; i++) { + if (String(temps[i]).toLowerCase() === 'nil') { + missing.push(variants[i]); + } + } + return missing; + } + + it('returns the names of variants whose nozzle_temperature is nil', () => { + const data = { + filament_extruder_variant: [ + 'Direct Drive Standard', 'Direct Drive High Flow', 'Bowden Standard', 'Bowden High Flow' + ], + nozzle_temperature: ['280', 'nil', 'nil', 'nil'] + }; + assert.deepStrictEqual( + extractMissingExtruderVariants(data), + ['Direct Drive High Flow', 'Bowden Standard', 'Bowden High Flow'] + ); + }); + + it('returns [] when every column has a value', () => { + const data = { + filament_extruder_variant: ['Direct Drive Standard', 'Direct Drive High Flow'], + nozzle_temperature: ['280', '290'] + }; + assert.deepStrictEqual(extractMissingExtruderVariants(data), []); + }); + + it('returns [] for single-variant presets', () => { + const data = { + filament_extruder_variant: ['Direct Drive Standard'], + nozzle_temperature: ['280'] + }; + assert.deepStrictEqual(extractMissingExtruderVariants(data), []); + }); + + it('returns [] when filament_extruder_variant is missing', () => { + assert.deepStrictEqual(extractMissingExtruderVariants({ nozzle_temperature: ['280', 'nil'] }), []); + }); + + it('returns [] when array lengths mismatch (skips detection safely)', () => { + const data = { + filament_extruder_variant: ['Direct Drive Standard', 'Direct Drive High Flow'], + nozzle_temperature: ['280'] + }; + assert.deepStrictEqual(extractMissingExtruderVariants(data), []); + }); + + it('returns [] for null/undefined preset data', () => { + assert.deepStrictEqual(extractMissingExtruderVariants(null), []); + assert.deepStrictEqual(extractMissingExtruderVariants(undefined), []); + }); + }); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `node --test scripts/generate-index-json.test.mjs` +Expected: the new `extractMissingExtruderVariants` tests are present and PASS on their own (they test an inline copy). This step confirms the *intended behavior* of the helper. Then proceed to put the real helper in the source module. + +> Note: because this file's convention is to test inline copies (like `cmp`, `normalizePosix`), these tests pass immediately. They lock the spec of the helper you add to the source in Step 3. If you prefer a true red phase, temporarily change one `assert` to a wrong value, watch it fail, then revert. + +- [ ] **Step 3: Add the helper to the source module** + +In `scripts/generate-index-json.mjs`, directly after the `extractCompatiblePrinters` function (it ends at the line with `return printers;` then `}` around line 105), add: + +```javascript +/** + * Find extruder/nozzle variants that have no tuned values. + * A preset is multi-variant when filament_extruder_variant has length > 1. + * The authority field is nozzle_temperature, aligned index-for-index. + * Any column equal to the string "nil" means that variant was not authored. + * @param {any} presetData + * @returns {string[]} names of missing variants (empty when none / not applicable) + */ +function extractMissingExtruderVariants(presetData) { + if (!presetData) return []; + const variants = presetData.filament_extruder_variant; + const temps = presetData.nozzle_temperature; + if (!Array.isArray(variants) || variants.length <= 1) return []; + if (!Array.isArray(temps) || temps.length !== variants.length) return []; + const missing = []; + for (let i = 0; i < variants.length; i++) { + if (String(temps[i]).toLowerCase() === 'nil') { + missing.push(variants[i]); + } + } + return missing; +} +``` + +- [ ] **Step 4: Wire the helper into the preset loop** + +In `scripts/generate-index-json.mjs`, find this block (around lines 153-163): + +```javascript + // Read the preset JSON content to extract compatible_printers + let compatiblePrinters = []; + if (extension === '.json') { + try { + const fileContent = await fs.readFile(absFile, 'utf8'); + const presetData = JSON.parse(fileContent); + compatiblePrinters = extractCompatiblePrinters(presetData); + } catch (e) { + console.warn(`Failed to parse ${relFromPreset}: ${e.message}`); + } + } +``` + +Replace it with: + +```javascript + // Read the preset JSON content to extract compatible_printers + let compatiblePrinters = []; + let missingExtruderVariants = []; + if (extension === '.json') { + try { + const fileContent = await fs.readFile(absFile, 'utf8'); + const presetData = JSON.parse(fileContent); + compatiblePrinters = extractCompatiblePrinters(presetData); + missingExtruderVariants = extractMissingExtruderVariants(presetData); + } catch (e) { + console.warn(`Failed to parse ${relFromPreset}: ${e.message}`); + } + } +``` + +Then find the `presets.push({ ... })` block (around lines 170-179): + +```javascript + presets.push({ + material: material, + brand: printerBrand, + model: printerModel, + slicer, + path: relPath, + filename, + updatedAt, + compatiblePrinters: compatiblePrinters + }); +``` + +Replace it with (conditionally include the field only when non-empty, so unaffected presets stay byte-identical): + +```javascript + presets.push({ + material: material, + brand: printerBrand, + model: printerModel, + slicer, + path: relPath, + filename, + updatedAt, + compatiblePrinters: compatiblePrinters, + ...(missingExtruderVariants.length ? { missingExtruderVariants } : {}) + }); +``` + +- [ ] **Step 5: Run the build tests to verify they pass** + +Run: `node --test scripts/generate-index-json.test.mjs` +Expected: PASS, all tests including the new block. + +- [ ] **Step 6: Commit** + +```bash +git add scripts/generate-index-json.mjs scripts/generate-index-json.test.mjs +git commit -m "feat(build): detect missing extruder variants for index.json + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 2: Regenerate index.json + +**Files:** +- Modify: `index.json` (generated) + +- [ ] **Step 1: Regenerate** + +Run: `npm run generate-index` +Expected: console prints `Generated index.json with presets.` + +- [ ] **Step 2: Verify the field landed correctly** + +Run: +```bash +node -e "const d=require('./index.json'); const a=d.presets.filter(p=>p.missingExtruderVariants&&p.missingExtruderVariants.length); console.log('affected presets:', a.length); console.log(JSON.stringify(a[0],null,1));" +``` +Expected: `affected presets:` is a positive number (in the current data, on the order of dozens), and the sample shows a `missingExtruderVariants` string array. + +- [ ] **Step 3: Confirm full test suite still green** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add index.json +git commit -m "chore: regenerate index.json with missingExtruderVariants + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 3: Selection-aggregation helper + tests + +**Files:** +- Create: `scripts/missing-variant-warning.test.mjs` +- Modify: `app.js` (add `collectMissingVariantWarnings` near the other download helpers, e.g. just above `function downloadSelectedPresets()` ~line 921) + +- [ ] **Step 1: Write the failing test file** + +Create `scripts/missing-variant-warning.test.mjs`: + +```javascript +import { describe, it } from 'node:test'; +import assert from 'node:assert'; + +// Mirrors collectMissingVariantWarnings() in app.js. +// Aggregates the currently-selected presets into one de-duplicated list of +// { material, model, variants } entries for presets that have missing variants. +function collectMissingVariantWarnings(selectedPresets) { + var seen = {}; + var items = []; + for (var key in selectedPresets) { + if (!Object.prototype.hasOwnProperty.call(selectedPresets, key)) continue; + var p = selectedPresets[key]; + var missing = p && p.missingExtruderVariants; + if (!missing || !missing.length) continue; + var dedupKey = (p.material || '') + '||' + (p.model || '') + '||' + missing.join(','); + if (seen[dedupKey]) continue; + seen[dedupKey] = true; + items.push({ material: p.material || '', model: p.model || '', variants: missing.slice() }); + } + return items; +} + +describe('collectMissingVariantWarnings', () => { + it('returns [] when nothing is selected', () => { + assert.deepStrictEqual(collectMissingVariantWarnings({}), []); + }); + + it('returns [] when no selected preset has missing variants', () => { + const sel = { + a: { material: 'PolyLite PLA', model: 'X1', missingExtruderVariants: [] }, + b: { material: 'Polymaker PLA', model: 'A1' } + }; + assert.deepStrictEqual(collectMissingVariantWarnings(sel), []); + }); + + it('collects affected presets with material, model and variant names', () => { + const sel = { + a: { material: 'PolyLite PLA', model: 'X2D', missingExtruderVariants: ['Direct Drive High Flow', 'Bowden Standard'] }, + b: { material: 'Polymaker PLA', model: 'A1', missingExtruderVariants: [] } + }; + assert.deepStrictEqual(collectMissingVariantWarnings(sel), [ + { material: 'PolyLite PLA', model: 'X2D', variants: ['Direct Drive High Flow', 'Bowden Standard'] } + ]); + }); + + it('de-duplicates identical material+model+variants entries', () => { + const sel = { + a: { material: 'PolyLite PLA', model: 'X2D', missingExtruderVariants: ['Bowden Standard'] }, + b: { material: 'PolyLite PLA', model: 'X2D', missingExtruderVariants: ['Bowden Standard'] } + }; + assert.strictEqual(collectMissingVariantWarnings(sel).length, 1); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify the spec passes for the inline copy** + +Run: `node --test scripts/missing-variant-warning.test.mjs` +Expected: PASS (inline copy). This locks the contract for the `app.js` function added next. + +- [ ] **Step 3: Add the function to `app.js`** + +In `app.js`, immediately above `function downloadSelectedPresets() {` (around line 921), add: + +```javascript + // Aggregate the selected presets into a de-duplicated list of + // { material, model, variants } for those missing extruder variants. + function collectMissingVariantWarnings(presets) { + var seen = {}; + var items = []; + for (var key in presets) { + if (!Object.prototype.hasOwnProperty.call(presets, key)) continue; + var p = presets[key]; + var missing = p && p.missingExtruderVariants; + if (!missing || !missing.length) continue; + var dedupKey = (p.material || '') + '||' + (p.model || '') + '||' + missing.join(','); + if (seen[dedupKey]) continue; + seen[dedupKey] = true; + items.push({ material: p.material || '', model: p.model || '', variants: missing.slice() }); + } + return items; + } +``` + +- [ ] **Step 4: Run the test suite** + +Run: `npm test` +Expected: PASS (new test file included via `scripts/*.test.mjs`). + +- [ ] **Step 5: Commit** + +```bash +git add app.js scripts/missing-variant-warning.test.mjs +git commit -m "feat(app): add collectMissingVariantWarnings selection aggregator + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 4: Modal markup, i18n keys, and styles + +**Files:** +- Modify: `index.html` (add modal after the `bambu-restart-modal` block, ~line 9146) +- Modify: `i18n.js` (add keys in the `en` block ~line 75 and the `zh` block ~line 215) +- Modify: `style.css` + +- [ ] **Step 1: Add the modal markup** + +In `index.html`, immediately after the closing `` of `#bambu-restart-modal` (the line after `9146`), insert: + +```html + +``` + +- [ ] **Step 2: Add English i18n keys** + +In `i18n.js`, in the `en` block right after `'modal.restart.confirm': 'Continue Download',` (line 75), add: + +```javascript + 'modal.missingvariant.title': '⚠️ Some Nozzle Options Have No Preset', + 'modal.missingvariant.intro': "Some selected presets don't include every nozzle/extruder option for this printer. We didn't make presets for these variants:", + 'modal.missingvariant.note': "You can still download — those nozzle options just won't have tuned values.", + 'modal.missingvariant.ack': 'Download anyway', + 'badge.missingvariant.title': 'No preset for these nozzle options:', +``` + +- [ ] **Step 3: Add Chinese i18n keys** + +In `i18n.js`, in the `zh` block right after `'modal.restart.confirm': ...` (around line 216; mirror the exact placement used in `en`), add: + +```javascript + 'modal.missingvariant.title': '⚠️ 部分喷嘴选项没有预设', + 'modal.missingvariant.intro': '所选的部分预设并未覆盖该打印机的全部喷嘴/挤出机选项。以下变体我们没有制作预设:', + 'modal.missingvariant.note': '你仍然可以下载 —— 这些喷嘴选项只是没有调校好的数值。', + 'modal.missingvariant.ack': '仍然下载', + 'badge.missingvariant.title': '以下喷嘴选项没有预设:', +``` + +- [ ] **Step 4: Add styles** + +Append to `style.css`: + +```css +/* Missing extruder-variant warning */ +.variant-warning-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-left: 6px; + border-radius: 50%; + background: #e0a800; + color: #fff; + cursor: help; + font-size: 11px; + font-weight: 700; + line-height: 1; + vertical-align: middle; +} + +.missing-variant-list { + margin: 12px 0; + padding-left: 20px; + list-style: disc; +} + +.missing-variant-list li { + margin-bottom: 6px; + line-height: 1.4; +} +``` + +- [ ] **Step 5: Sanity-check the page still loads** + +Run: `node -e "const fs=require('fs');const h=fs.readFileSync('index.html','utf8');if(!h.includes('missing-variant-modal'))throw new Error('modal markup missing');console.log('ok');"` +Expected: `ok`. + +- [ ] **Step 6: Commit** + +```bash +git add index.html i18n.js style.css +git commit -m "feat(ui): add missing-variant warning modal markup, i18n, styles + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 5: Wire the modal + guard the bulk download functions + +**Files:** +- Modify: `app.js` (modal state/show/init near `bambuRestartModalState` ~line 1901-1969; init call ~line 2245; guards in `downloadSelectedPresets` ~line 921 and `downloadSelectedBundle` ~line 1043) + +- [ ] **Step 1: Add modal state, show, and init functions** + +In `app.js`, immediately after the `showBambuRestartWarning` function (it ends around line 1969), add: + +```javascript +var missingVariantModalState = { + onAck: null +}; + +function showMissingVariantWarning(items, onAck) { + var modal = document.getElementById('missing-variant-modal'); + if (!modal || !items || !items.length) { + if (onAck) onAck(); + return; + } + + var listEl = document.getElementById('missing-variant-list'); + if (listEl) { + var html = ''; + for (var i = 0; i < items.length; i++) { + html += '
  • ' + escapeHtml(items[i].material) + ' — ' + escapeHtml(items[i].model) + + ': ' + escapeHtml(items[i].variants.join(', ')) + '
  • '; + } + listEl.innerHTML = html; + } + + missingVariantModalState.onAck = onAck; + modal.setAttribute('aria-hidden', 'false'); + modal.classList.add('is-open'); + document.body.style.overflow = 'hidden'; +} + +function initMissingVariantModal() { + var modal = document.getElementById('missing-variant-modal'); + if (!modal) return; + + var closeBtn = document.getElementById('missing-variant-modal-close'); + var ackBtn = document.getElementById('missing-variant-ack'); + var overlay = modal.querySelector('.modal-overlay'); + + // Acknowledge-only: every dismissal path proceeds with the download. + function ack() { + modal.setAttribute('aria-hidden', 'true'); + modal.classList.remove('is-open'); + document.body.style.overflow = ''; + var cb = missingVariantModalState.onAck; + missingVariantModalState.onAck = null; + if (cb) cb(); + } + + if (closeBtn) closeBtn.addEventListener('click', ack); + if (ackBtn) ackBtn.addEventListener('click', ack); + if (overlay) overlay.addEventListener('click', ack); + + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && modal.classList.contains('is-open')) { + ack(); + } + }); +} +``` + +- [ ] **Step 2: Call the init** + +In `app.js`, find `initBambuRestartModal();` (around line 2245) and add directly after it: + +```javascript +initMissingVariantModal(); +``` + +- [ ] **Step 3: Guard `downloadSelectedPresets`** + +In `app.js`, change the signature and add the guard at the very top. Find: + +```javascript + function downloadSelectedPresets() { + var presetIds = Object.keys(selectedPresets); + if (presetIds.length === 0) return; +``` + +Replace with: + +```javascript + function downloadSelectedPresets(skipVariantWarning) { + var presetIds = Object.keys(selectedPresets); + if (presetIds.length === 0) return; + + if (!skipVariantWarning) { + var variantWarnings = collectMissingVariantWarnings(selectedPresets); + if (variantWarnings.length) { + showMissingVariantWarning(variantWarnings, function () { + downloadSelectedPresets(true); + }); + return; + } + } +``` + +- [ ] **Step 4: Guard `downloadSelectedBundle`** + +In `app.js`, find: + +```javascript + function downloadSelectedBundle() { + // Filter for BambuStudio presets only + var bambuPresets = []; +``` + +Replace with: + +```javascript + function downloadSelectedBundle(skipVariantWarning) { + if (!skipVariantWarning) { + var variantWarnings = collectMissingVariantWarnings(selectedPresets); + if (variantWarnings.length) { + showMissingVariantWarning(variantWarnings, function () { + downloadSelectedBundle(true); + }); + return; + } + } + // Filter for BambuStudio presets only + var bambuPresets = []; +``` + +- [ ] **Step 5: Carry the field into the per-preset selection payload** + +In `app.js` (around line 1555), find: + +```javascript + var presetData = JSON.stringify({ url: url, filename: filename, slicer: p.slicer, path: p.path, material: p.material, model: p.model, brand: p.brand }); +``` + +Replace with: + +```javascript + var presetData = JSON.stringify({ url: url, filename: filename, slicer: p.slicer, path: p.path, material: p.material, model: p.model, brand: p.brand, missingExtruderVariants: p.missingExtruderVariants || [] }); +``` + +- [ ] **Step 6: Run the suite** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 7: Manual smoke check in a browser** + +Serve the folder (e.g. `python3 -m http.server 8080`), open `http://localhost:8080`, select BambuStudio, select an X2D material known to have a `nil` variant (e.g. a `Polymaker HT-PLA @BBL X2D` preset), check its box, and click **Download Selected**. +Expected: the modal appears listing the material — X2D and the missing variant names; clicking **Download anyway** dismisses it and the ZIP download starts. Selecting only fully-populated presets shows no modal. + +- [ ] **Step 8: Commit** + +```bash +git add app.js +git commit -m "feat(app): show missing-variant warning before bulk downloads + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 6: Inline badge on affected rows + +**Files:** +- Modify: `app.js` (`generatePresetRowHtml` ~line 1554-1586; folder row ~line 1610-1615) + +- [ ] **Step 1: Add the badge to preset rows** + +In `app.js`, inside `generatePresetRowHtml`, find the start of the `return '?' + : ''; +``` + +Then in the same `return`, change the material cell. Find: + +```javascript + '' + escapeHtml(options.material) + '' + +``` + +Replace with: + +```javascript + '' + escapeHtml(options.material) + variantBadgeHtml + '' + +``` + +- [ ] **Step 2: Add the badge to folder (parent) rows** + +In `app.js`, find the folder-row push (around lines 1610-1615): + +```javascript + rowsHtml.push('' + + '' + + '' + folderIconSvg + escapeHtml(mat) + ' (' + t('folder.presets', { n: list.length }) + ')' + + '-' + + '' + t('folder.expand') + '' + + ''); +``` + +Immediately *before* that push, compute the aggregated badge: + +```javascript + var folderMissing = []; + list.forEach(function (cp) { + if (cp.missingExtruderVariants && cp.missingExtruderVariants.length) { + cp.missingExtruderVariants.forEach(function (v) { + if (folderMissing.indexOf(v) === -1) folderMissing.push(v); + }); + } + }); + var folderBadgeHtml = folderMissing.length + ? ' ?' + : ''; +``` + +Then in the push, change the material cell. Find: + +```javascript + '' + folderIconSvg + escapeHtml(mat) + ' (' + t('folder.presets', { n: list.length }) + ')' + +``` + +Replace with: + +```javascript + '' + folderIconSvg + escapeHtml(mat) + folderBadgeHtml + ' (' + t('folder.presets', { n: list.length }) + ')' + +``` + +- [ ] **Step 3: Run the suite** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 4: Manual smoke check** + +Reload the served page, select BambuStudio + an affected printer/material. The affected rows (and their folder header when grouped) show a `?` badge; hovering shows the missing variant names. Fully-populated rows show no badge. + +- [ ] **Step 5: Commit** + +```bash +git add app.js +git commit -m "feat(ui): show inline warning badge on presets missing variants + +Co-Authored-By: Claude Fable 5 " +``` + +--- + +## Task 7: Final verification + +- [ ] **Step 1: Full suite** + +Run: `npm test` +Expected: PASS. + +- [ ] **Step 2: Regeneration is idempotent** + +Run: `npm run generate-index && git status --porcelain index.json` +Expected: no unexpected churn beyond the regeneration timestamp line; `missingExtruderVariants` fields stable. + +- [ ] **Step 3: End-to-end manual pass** + +With the page served: (a) affected rows show the badge; (b) bulk **Download Selected** and **Download .bbsflmt Bundle** both show the modal when an affected preset is selected; (c) acknowledging proceeds with the download; (d) no modal when only fully-populated presets are selected; (e) switch language to 中文 and confirm the modal + badge tooltip strings are translated. diff --git a/docs/superpowers/specs/2026-06-16-missing-extruder-variant-warning-design.md b/docs/superpowers/specs/2026-06-16-missing-extruder-variant-warning-design.md new file mode 100644 index 00000000..1a8ea6de --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-missing-extruder-variant-warning-design.md @@ -0,0 +1,109 @@ +# Missing Extruder-Variant Download Warning + +## Problem + +Bambu Lab printers with more than one extruder/nozzle option (e.g. X2D, H2D, H2C, H2S, P2S, X1) store per-variant values inside each filament preset as arrays aligned to `filament_extruder_variant`. When a column is the string `"nil"`, that nozzle/extruder option has **no tuned values** — Polymaker did not author a preset for it (this includes the auxiliary "Bowden" nozzle on dual-nozzle machines). + +A user who downloads such a preset and prints with one of those nozzle options gets no tuned values and no explanation. We need to make the gap visible. + +In the current `preset/` data, 6 BBL models expose multi-variant presets, and 79 of 171 multi-variant presets have at least one `"nil"` column. Because the gap is common, the warning must be ambient and non-annoying rather than a constant interruption. + +## Goal + +1. Detect, per preset, exactly which extruder variants have no values. +2. Surface that ambiently with an inline badge on affected presets. +3. On download, show a single aggregated, acknowledge-only modal naming the missing variants so the user is never surprised. + +Out of scope: changing any preset contents, authoring the missing presets, or altering slicer behavior. + +## Detection rule (build-time) + +Implemented in `scripts/generate-index-json.mjs`. + +- A preset is **multi-variant** when its JSON has `filament_extruder_variant` as an array of length > 1. +- The authority field is `nozzle_temperature` (an array aligned index-for-index with `filament_extruder_variant`). +- For each index `i`, if `String(nozzle_temperature[i]) === "nil"`, the variant named `filament_extruder_variant[i]` is **missing**. +- The broad rule applies: **any** `nil` column counts as missing (no special-casing of High Flow vs Bowden). + +Example: `filament_extruder_variant = ["Direct Drive Standard","Direct Drive High Flow","Bowden Standard","Bowden High Flow"]` with `nozzle_temperature = ["280","nil","nil","nil"]` yields missing `["Direct Drive High Flow","Bowden Standard","Bowden High Flow"]`. + +Edge cases: +- `filament_extruder_variant` absent or length ≤ 1 → not multi-variant → no field emitted. +- `nozzle_temperature` missing, not an array, or a different length than `filament_extruder_variant` → skip detection for that preset and `console.warn` (consistent with existing parse-failure handling); emit no field. +- No `nil` columns → emit no field (so unaffected presets stay untouched). + +## `index.json` schema change + +Each affected preset object gains one field, omitted entirely when nothing is missing: + +```json +"missingExtruderVariants": ["Direct Drive High Flow", "Bowden Standard", "Bowden High Flow"] +``` + +The app reads this list directly and never parses preset internals. + +## App behavior + +### Inline badge (ambient signal) + +In `app.js`, `generatePresetRowHtml(p, options)` (around line 1554) renders each preset ``. When `p.missingExtruderVariants` is a non-empty array, render a small warning badge in the material cell next to the preset name. + +- The badge is a static element with a `title` (tooltip) listing the missing variant names. +- Purely visual; it does not block or alter the row's download buttons. +- Folder (parent) rows show the badge when any child preset is affected. + +### Download-time modal (explicit confirm) + +The two download entry points both gain a guard at the top before they begin fetching/zipping: + +- `downloadSelectedPresets()` (around line 921) +- `downloadSelectedBundle()` (around line 1043) + +Guard logic: + +1. From the selected presets, collect those with a non-empty `missingExtruderVariants`. +2. If none, proceed unchanged. +3. If any, show one aggregated modal. The download proceeds only after the user clicks the single acknowledge button; the modal is informational, so there is no Cancel — acknowledging continues the existing download flow. + +The modal aggregates by material + printer model, listing the exact missing variant names per entry. One modal per download action — never one per preset. + +```mermaid +flowchart TD + A[User clicks a download button] --> B{Any selected preset has
    missingExtruderVariants?} + B -- No --> D[Run existing download flow] + B -- Yes --> C[Show one aggregated modal:
    material + printer to missing variants] + C --> E[User clicks acknowledge] + E --> D +``` + +### Modal content + +All strings route through `i18n.js` (`t(...)`), matching existing usage. The body names exact variants, for example: + +> Some selected presets don't include every nozzle option for this printer. We didn't make presets for these extruder/nozzle variants: +> - PolyLite PLA — Bambu Lab X2D: Direct Drive High Flow, Bowden Standard, Bowden High Flow +> +> You can still download — those nozzle options just won't have tuned values. + +Button: a single acknowledge action labeled to make clear the download continues (e.g. "Download anyway"). + +## Testing + +- `scripts/generate-index-json.test.mjs`: + - Fixture preset with `nozzle_temperature: ["280","nil"]` and matching 2-entry `filament_extruder_variant` → asserts `missingExtruderVariants: ["Direct Drive High Flow"]`. + - Fully-populated multi-variant preset → field absent. + - Single-variant / missing `filament_extruder_variant` → field absent. + - Length-mismatch between the two arrays → field absent (and warned), not a crash. +- App-level test (in the style of `scripts/app-filter.test.mjs`): + - Given a selection containing presets with `missingExtruderVariants`, the guard returns the correct aggregated material→variant mapping and a "modal needed" flag. + - Given a selection with none, the guard reports "no modal". + +## Files touched + +- `scripts/generate-index-json.mjs` — detection + new field. +- `scripts/generate-index-json.test.mjs` — detection tests. +- `index.json` — regenerated with the new field. +- `app.js` — badge rendering in `generatePresetRowHtml`; guard + modal in the two download paths. +- `i18n.js` — new message keys. +- `style.css` — badge and modal styles. +- A new app-level test for the guard. diff --git a/i18n.js b/i18n.js index bf244443..442bb9cf 100644 --- a/i18n.js +++ b/i18n.js @@ -73,6 +73,10 @@ var I18N = (function () { 'modal.restart.link': 'View GitHub Issue #10583 \u2192', 'modal.restart.cancel': 'Cancel', 'modal.restart.confirm': 'Continue Download', + 'modal.missingvariant.title': '⚠️ Some Nozzle Options Have No Preset', + 'modal.missingvariant.intro': "Some selected presets don't include every nozzle/extruder option for this printer. We didn't make presets for these variants:", + 'modal.missingvariant.note': "You can still download — those nozzle options just won't have tuned values.", + 'modal.missingvariant.ack': 'Continue Download', // Install modal 'modal.install.title': '\ud83d\udce6 Manual Installation', @@ -125,6 +129,8 @@ var I18N = (function () { 'issues.restart.issue': 'Issue: BambuStudio may not correctly apply newly imported filament presets until the application is restarted. Slicing or printing without restarting may use incorrect temperature, flow rate, or other filament settings.', 'issues.restart.solution': 'Solution: Always restart BambuStudio after importing Polymaker presets, before you start slicing or printing. A warning popup will also remind you when downloading BambuStudio presets from this page.', 'issues.restart.link': 'View BambuStudio Issue #10583 \u2192', + 'issues.aux.title': 'Missing Presets for Some Nozzle / Extruder Options', + 'issues.aux.issue': 'Issue: Bambu Lab printers that offer more than one extruder/nozzle option (such as the multi-nozzle X2D and the H2 series) store a separate set of values for each option \u2014 Direct Drive Standard, Direct Drive High Flow, Bowden Standard, and Bowden High Flow \u2014 inside a single filament preset. For some materials we have only tuned the main option (usually Direct Drive Standard), so the other nozzle options, including the auxiliary (Bowden) nozzle, are left empty and have no tuned values.', // Footer 'footer.links': 'Links', @@ -215,6 +221,10 @@ var I18N = (function () { 'modal.restart.link': '查看 GitHub Issue #10583 \u2192', 'modal.restart.cancel': '取消', 'modal.restart.confirm': '继续下载', + 'modal.missingvariant.title': '⚠️ 部分喷嘴选项没有预设', + 'modal.missingvariant.intro': '所选的部分预设并未覆盖该打印机的全部喷嘴/挤出机选项。以下变体我们没有制作预设:', + 'modal.missingvariant.note': '你仍然可以下载 —— 这些喷嘴选项只是没有调校好的数值。', + 'modal.missingvariant.ack': '继续下载', // Install modal 'modal.install.title': '📦 手动安装', @@ -266,6 +276,8 @@ var I18N = (function () { 'issues.restart.title': '导入 BambuStudio 预设后需要重启软件', 'issues.restart.issue': '问题:BambuStudio 在重启前可能无法正确应用新导入的耗材预设。未重启直接切片或打印可能会导致温度、流量或其他耗材设置错误。', 'issues.restart.solution': '解决方案:导入 Polymaker 预设后,在开始切片或打印前请务必重启 BambuStudio。从本页面下载 BambuStudio 预设时,也会弹出警告提醒您。', + 'issues.aux.title': '部分喷嘴 / 挤出机选项缺少预设', + 'issues.aux.issue': '问题:提供多个挤出机/喷嘴选项的 Bambu Lab 打印机(例如多喷嘴的 X2D 和 H2 系列)会在同一个耗材预设中为每个选项分别存储一组数值——Direct Drive StandardDirect Drive High FlowBowden StandardBowden High Flow。对于部分材料,我们只调校了主选项(通常是 Direct Drive Standard),因此其他喷嘴选项(包括辅助的 Bowden 喷嘴)为空,没有调校好的数值。', 'issues.restart.link': '查看 BambuStudio Issue #10583 \u2192', // Footer diff --git a/index.html b/index.html index ce0e3160..131f9abf 100644 --- a/index.html +++ b/index.html @@ -8980,6 +8980,17 @@

    Known Issues

    + @@ -9145,6 +9156,31 @@