From a4982f9db44c8908cf87622821a56bf915b39998 Mon Sep 17 00:00:00 2001 From: thrtnastrx Date: Fri, 3 Jul 2026 01:58:44 -0700 Subject: [PATCH] Add folder/ZIP bundle grouping, multi-model preview, and slicer integration. Group related STLs by folder or zip archive, preview all bundle parts in one 3D layout, send models to configured slicers from preview (new instance on macOS), and document behavior in CHANGELOG, GUIDE, and README. Co-authored-by: Cursor --- .gitignore | 2 + CHANGELOG.md | 25 +++ GUIDE.md | 26 ++- README.md | 5 + bundle-keys.js | 48 +++++ bundle-keys.test.js | 39 ++++ guide.js | 3 +- index.html | 38 ++++ main.js | 337 +++++++++++++++++++++++------ package-lock.json | 4 +- package.json | 2 + preload.js | 1 + preview.js | 507 ++++++++++++++++++++++++++++++++++---------- renderer.js | 353 +++++++++++++++++++++++++++--- server-bridge.js | 1 + slicer.js | 2 + styles.css | 120 ++++++++++- 17 files changed, 1302 insertions(+), 211 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 bundle-keys.js create mode 100644 bundle-keys.test.js diff --git a/.gitignore b/.gitignore index 19961a0..adf2a14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ dist node_modules printventory.db +printventory.db-shm +printventory.db-wal package-lock.json test-files.zip git_credentials diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7781731 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +All notable changes contributed via pull request are documented in this file. + +## [Unreleased] + +### Added + +- **Folder and ZIP bundle grouping** — When scanning, models that share the same parent folder or the same ZIP archive (2+ files) are grouped into a single row in List, Preview, and Detailed views. Single-file folders stay as individual entries. +- **Bundle 3D preview** — Click a folder or ZIP bundle to open one preview dialog showing every STL/3MF part laid out on a grid, with per-part colors for clarity. Up to 32 previewable parts per bundle. +- **Bundle details panel** — Double-click a bundle (or use **Open 3D preview** from the panel) to see path, combined size, print status, and a sortable file list. Chevron still expands/collapses the bundle in the grid. +- **Send to Slicer in preview** — The 3D preview dialog includes a **Send to Slicer** button. Works for single models and full bundles (all STL/3MF paths). If multiple slicers are configured, a picker is shown. +- **New slicer instance on send** — macOS launches slicers with `open -n` so a new window opens even when the slicer is already running. Prusa-family binaries also receive `--single-instance=0` when launched directly. +- **`bundle-keys.js`** — Shared logic to derive `bundleKey`, `bundleLabel`, and `bundleKind` from file paths (including `zipPath::entry` paths). +- **`npm run test:bundle`** — Unit tests for bundle key derivation. + +### Changed + +- Scan insert/update and `saveModel` persist bundle metadata (`bundleKey`, `bundleLabel`, `bundleKind`) with automatic migration on startup. +- Context menu **Open in Slicer** and preview **Send to Slicer** share the same launch helper (`buildSlicerLaunchCommand` / `open-file-in-slicer` IPC). +- `window.openSlicerSettings` is exposed from `slicer.js` for use from the preview flow. + +### Database + +- New optional columns on `models`: `bundleKey`, `bundleLabel`, `bundleKind` (backfilled on existing databases). diff --git a/GUIDE.md b/GUIDE.md index 2fe91cc..b959199 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -19,6 +19,7 @@ Printventory is a desktop application for managing your 3D printing model collec - Track print status - Add source URLs and notes - Link related models (parent/child relationships) +- **Folder and ZIP bundles**: Models in the same subfolder or ZIP archive (2+ files) appear as one grouped row; click to preview all parts in 3D, double-click for bundle details - Assign licenses to models ### Multi-Edit Features @@ -162,8 +163,26 @@ Printventory offers comprehensive settings to customize your experience: - Rendering performance optimization ### Slicer Path -- Configure path to your slicer application -- Used for opening models in external slicers +- Configure one or more slicer applications (name + path) +- **Open in Slicer** from the right-click context menu +- **Send to Slicer** from the 3D preview dialog (single model or full bundle) +- On macOS, each send opens a **new slicer instance** so models load even when the slicer is already open + +### Bundle groups (folders and ZIP archives) + +When a scan finds **two or more** STL/3MF files in the same folder or inside the same ZIP file, Printventory shows them as one **bundle** row instead of many separate entries. + +| Action | Result | +|--------|--------| +| Click bundle row | Opens **3D preview** with every part laid out on a grid | +| Double-click bundle row | Opens **Bundle details** (path, sizes, print status, file list) | +| Chevron (▸ / ▾) | Expand or collapse individual files in the grid | +| **Send to Slicer** (in preview) | Sends all bundle STL/3MF files to your chosen slicer | + +**Notes:** +- Single-file folders are not grouped (they stay normal model rows). +- Bundle preview supports up to 32 STL/3MF parts per open; larger bundles show the first 32 with a notice. +- ZIP entries are extracted to a temp file before sending to the slicer, same as the context menu. ### STL Home - Set default directory for file operations @@ -181,6 +200,7 @@ Printventory offers comprehensive settings to customize your experience: - Enable multi-edit mode for batch operations - Use the Tag Manager to organize and clean up tags across your collection - Link related models using parent/child relationships +- Look for **folder** and **ZIP** bundle rows when a multi-part project was scanned together; click to preview all parts at once ### AI Tagging - Start with a small batch to test your AI configuration @@ -189,7 +209,7 @@ Printventory offers comprehensive settings to customize your experience: - Review AI-suggested tags before accepting to ensure accuracy ### View Modes -- Use List view for quick scanning of large collections +- Use List view for quick scanning of large collections; bundle groups appear as one row with a part count - Use Detailed view when reviewing models for printing - Preview view offers a good balance between information and space diff --git a/README.md b/README.md index 178b43e..db994f5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Printventory is an Electron-based desktop application for managing your 3D print - **Source URLs**: Store links to where you found or purchased models - **Notes**: Add custom notes to any model - **Parent/Child Relationships**: Link related models together +- **Folder & ZIP bundle grouping**: Automatically group models from the same subfolder or ZIP archive; preview all parts in one 3D view - **License Tracking**: Assign licenses to models ### Advanced Features @@ -29,6 +30,8 @@ Printventory is an Electron-based desktop application for managing your 3D print - **Duplicate Detection**: Find duplicate files based on content hash with visual comparison - **Print Roulette**: Randomly select models from your collection - **AI Tagging**: Automated tag suggestions using AI +- **3D bundle preview**: Open every STL/3MF in a folder or ZIP in a single preview layout +- **Send to Slicer from preview**: Open the current model or entire bundle in your configured slicer (new instance when already running) - **Search & Filter**: Real-time search by filename and filter by designer, tags, print status, parent model, or license - **Tag Manager**: Comprehensive tag management interface - **Metadata Editor**: Bulk metadata editing capabilities @@ -42,6 +45,8 @@ Printventory is an Electron-based desktop application for managing your 3D print For a complete list of features and detailed usage instructions, see the [GUIDE.md](GUIDE.md) file. +See [CHANGELOG.md](CHANGELOG.md) for recent feature additions and migration notes. + ## Installation ### Pre-built Releases diff --git a/bundle-keys.js b/bundle-keys.js new file mode 100644 index 0000000..3e6bc87 --- /dev/null +++ b/bundle-keys.js @@ -0,0 +1,48 @@ +/** + * Derive folder / zip bundle identity from a model filePath. + * Shared by main process (scan, save) and tests. + */ +const path = require('path'); + +function normalizePath(filepath) { + return String(filepath || '').replace(/\\/g, '/'); +} + +/** + * @param {string} filePath + * @returns {{ bundleKey: string, bundleLabel: string, bundleKind: string }} + */ +function deriveBundleFromFilePath(filePath) { + const empty = { bundleKey: '', bundleLabel: '', bundleKind: '' }; + if (!filePath || typeof filePath !== 'string') return empty; + if (filePath.startsWith('url::')) return empty; + + if (filePath.includes('::')) { + const zipPath = filePath.split('::')[0]; + const normalized = normalizePath(zipPath); + if (!normalized) return empty; + const label = path.basename(normalized) || normalized; + return { + bundleKey: `zip:${normalized.toLowerCase()}`, + bundleLabel: label, + bundleKind: 'zip', + }; + } + + const dir = path.dirname(filePath); + const normalizedDir = normalizePath(dir); + if (!normalizedDir || normalizedDir === '.' || normalizedDir === '/') { + return empty; + } + const label = path.basename(normalizedDir) || normalizedDir; + return { + bundleKey: `folder:${normalizedDir.toLowerCase()}`, + bundleLabel: label, + bundleKind: 'folder', + }; +} + +module.exports = { + deriveBundleFromFilePath, + normalizePath, +}; diff --git a/bundle-keys.test.js b/bundle-keys.test.js new file mode 100644 index 0000000..7221405 --- /dev/null +++ b/bundle-keys.test.js @@ -0,0 +1,39 @@ +#!/usr/bin/env node +'use strict'; + +const assert = require('assert'); +const { deriveBundleFromFilePath } = require('./bundle-keys'); + +function test(name, fn) { + try { + fn(); + console.log(`ok ${name}`); + } catch (err) { + console.error(`FAIL ${name}:`, err.message); + process.exitCode = 1; + } +} + +test('folder bundle from sibling STLs', () => { + const stem = deriveBundleFromFilePath('/Downloads/flower-model/stem.stl'); + const petal = deriveBundleFromFilePath('/Downloads/flower-model/petal.stl'); + assert.strictEqual(stem.bundleKind, 'folder'); + assert.strictEqual(stem.bundleLabel, 'flower-model'); + assert.strictEqual(stem.bundleKey, petal.bundleKey); +}); + +test('zip bundle from archive entry', () => { + const entry = deriveBundleFromFilePath('C:\\Models\\flower.zip::parts/flower.stl'); + assert.strictEqual(entry.bundleKind, 'zip'); + assert.strictEqual(entry.bundleLabel, 'flower.zip'); + assert.ok(entry.bundleKey.startsWith('zip:')); +}); + +test('url models have no bundle', () => { + const url = deriveBundleFromFilePath('url::https://example.com/model.stl'); + assert.strictEqual(url.bundleKey, ''); +}); + +if (process.exitCode) { + process.exit(process.exitCode); +} diff --git a/guide.js b/guide.js index 1dfe574..0250c24 100644 --- a/guide.js +++ b/guide.js @@ -71,7 +71,8 @@ const guidePages = [
  • Backup/Restore – Safeguard your data with easy backup and restore options! 💾
  • De-Dup – Say goodbye to clutter! Easily clean up duplicate files in your library, ensuring your collection stays organized and efficient! 🧹
  • AI Tagging – Configure your AI services in Settings > AI Config to enable powerful AI-assisted tagging! Right-click on one or multiple models to "Generate Tags using AI" and automatically categorize your models. 🤖
  • -
  • Slicer Integration – Set your preferred slicer path in Settings to enable the "Open in Slicer" right-click option. Quickly prepare models for printing with your favorite slicer! 🖨️
  • +
  • Slicer Integration – Configure slicers in Settings for right-click Open in Slicer and the preview dialog Send to Slicer button. Sending again while a slicer is open starts a new instance with your model loaded. 🖨️
  • +
  • Folder & ZIP bundles – Multi-part folders and ZIP archives group into one row. Click for an all-parts 3D preview; double-click for bundle details. 📦
  • Thank you for choosing Printventory! Visit Help > Support Printventory to learn how you can support this amazing project!`, image: "" diff --git a/index.html b/index.html index f925430..384744b 100644 --- a/index.html +++ b/index.html @@ -271,6 +271,40 @@

    Model Details

    + +

    Add New Designer

    @@ -910,6 +944,9 @@

    Model Preview

    + @@ -920,6 +957,7 @@

    Model Preview

    📐 Axes
    +
    diff --git a/main.js b/main.js index f728a4b..509bd00 100644 --- a/main.js +++ b/main.js @@ -5,6 +5,7 @@ const Database = require('better-sqlite3'); const crypto = require('crypto'); const puppeteer = require('puppeteer'); const { Worker } = require('worker_threads'); +const { deriveBundleFromFilePath } = require('./bundle-keys'); // 3MF preview worker/caching const preview3mfWorkers = new Map(); const preview3mfCache = new Map(); @@ -1632,7 +1633,10 @@ function initializeDatabase() { license TEXT, modifiedDate DATETIME, dateAdded DATETIME, - isNew INTEGER DEFAULT 1 + isNew INTEGER DEFAULT 1, + bundleKey TEXT, + bundleLabel TEXT, + bundleKind TEXT )`).run(); // Create tags table @@ -1691,6 +1695,7 @@ function initializeDatabase() { // This must run before creating indexes on dateAdded migrateDateAddedColumn(); migrateIsNewColumn(); + migrateBundleColumns(); // Create index for dateAdded after migration (in case it was just added) db.prepare('CREATE INDEX IF NOT EXISTS idx_models_dateadded ON models(dateAdded)').run(); @@ -1775,6 +1780,48 @@ function migrateIsNewColumn() { } } +/** Folder / zip bundle columns for grouped browsing. */ +function migrateBundleColumns() { + try { + console.log('Checking for bundle column migration...'); + const tableInfo = db.prepare('PRAGMA table_info(models)').all(); + const names = new Set(tableInfo.map((col) => col.name)); + const additions = [ + ['bundleKey', 'TEXT'], + ['bundleLabel', 'TEXT'], + ['bundleKind', 'TEXT'], + ]; + for (const [col, ddl] of additions) { + if (!names.has(col)) { + db.prepare(`ALTER TABLE models ADD COLUMN ${col} ${ddl}`).run(); + console.log(`Added models.${col}`); + } + } + db.prepare('CREATE INDEX IF NOT EXISTS idx_models_bundlekey ON models(bundleKey)').run(); + + const rows = db.prepare( + "SELECT id, filePath FROM models WHERE bundleKey IS NULL OR bundleKey = ''" + ).all(); + if (rows.length > 0) { + const update = db.prepare( + 'UPDATE models SET bundleKey = ?, bundleLabel = ?, bundleKind = ? WHERE id = ?' + ); + const backfill = db.transaction(() => { + for (const row of rows) { + const bundle = deriveBundleFromFilePath(row.filePath); + update.run(bundle.bundleKey || null, bundle.bundleLabel || null, bundle.bundleKind || null, row.id); + } + }); + backfill(); + console.log(`Backfilled bundle fields for ${rows.length} model(s)`); + } + return true; + } catch (error) { + console.error('Error migrating bundle columns:', error); + return false; + } +} + // Add this function to clean up any database objects referencing models_old function cleanupModelsOldReferences() { try { @@ -2851,14 +2898,20 @@ ipcMain.handle('scan-directory', async (event, directoryPath, options = {}) => { // Otherwise every scan would overwrite hashes with '' and trigger full hash regeneration on each start. const updateExisting = db.prepare(` UPDATE models - SET hash = COALESCE(NULLIF(?, ''), hash), size = ?, modifiedDate = ? + SET hash = COALESCE(NULLIF(?, ''), hash), + size = ?, + modifiedDate = ?, + bundleKey = ?, + bundleLabel = ?, + bundleKind = ? WHERE filePath = ? `); const insertNew = db.prepare(` INSERT INTO models ( - filePath, fileName, hash, size, modifiedDate, dateAdded, isNew - ) VALUES (?, ?, ?, ?, ?, ?, 1) + filePath, fileName, hash, size, modifiedDate, dateAdded, isNew, + bundleKey, bundleLabel, bundleKind + ) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) `); // Track count of newly inserted files @@ -2884,12 +2937,16 @@ ipcMain.handle('scan-directory', async (event, directoryPath, options = {}) => { const batch = files.slice(i, i + batchSize); for (const file of batch) { + const bundle = deriveBundleFromFilePath(file.filePath); // Use Set lookup instead of database query - O(1) vs O(log n) database query if (existingFilePaths.has(file.filePath)) { updateExisting.run( file.hash || '', file.size, file.mtime.toISOString(), + bundle.bundleKey || null, + bundle.bundleLabel || null, + bundle.bundleKind || null, file.filePath ); } else { @@ -2900,7 +2957,10 @@ ipcMain.handle('scan-directory', async (event, directoryPath, options = {}) => { file.hash || '', file.size, file.mtime.toISOString(), - dateAdded + dateAdded, + bundle.bundleKey || null, + bundle.bundleLabel || null, + bundle.bundleKind || null ); newFilesCount++; // Add to set so we don't try to insert duplicates within the same transaction @@ -3173,7 +3233,7 @@ const getAllModelsHandler = async (event, sortOption, limit = 0) => { break; } - const selectCols = "id, filePath, fileName, designer, source, notes, printed, parentModel, hash, size, license, modifiedDate, dateAdded, isNew, CASE WHEN thumbnail IS NOT NULL AND thumbnail != '' AND thumbnail != '3d.png' THEN 1 ELSE 0 END AS hasThumbnail"; + const selectCols = "id, filePath, fileName, designer, source, notes, printed, parentModel, hash, size, license, modifiedDate, dateAdded, isNew, bundleKey, bundleLabel, bundleKind, CASE WHEN thumbnail IS NOT NULL AND thumbnail != '' AND thumbnail != '3d.png' THEN 1 ELSE 0 END AS hasThumbnail"; let models; if (limit === 0) { @@ -3772,7 +3832,7 @@ const getModelsFilteredHandler = async (event, filters) => { break; } - const selectCols = "models.id, models.filePath, models.fileName, models.designer, models.source, models.notes, models.printed, models.parentModel, models.hash, models.size, models.license, models.modifiedDate, models.dateAdded, models.isNew, CASE WHEN models.thumbnail IS NOT NULL AND models.thumbnail != '' AND models.thumbnail != '3d.png' THEN 1 ELSE 0 END AS hasThumbnail"; + const selectCols = "models.id, models.filePath, models.fileName, models.designer, models.source, models.notes, models.printed, models.parentModel, models.hash, models.size, models.license, models.modifiedDate, models.dateAdded, models.isNew, models.bundleKey, models.bundleLabel, models.bundleKind, CASE WHEN models.thumbnail IS NOT NULL AND models.thumbnail != '' AND models.thumbnail != '3d.png' THEN 1 ELSE 0 END AS hasThumbnail"; // Execute query (optional limit/offset for progressive load when clearing filters in Server/Docker) // SQLite requires LIMIT when using OFFSET; use a large limit when only offset is set @@ -5584,7 +5644,6 @@ ipcMain.handle('show-context-menu', async (event, fileIdentifier) => { } // Execute slicer command (only in normal mode, not server mode) - const { exec } = require('child_process'); let modelPath = filePaths[0]; // Use the first file selected // If it's a zip entry, extract to temp first @@ -5597,23 +5656,8 @@ ipcMain.handle('show-context-menu', async (event, fileIdentifier) => { console.error('[Slicer] Blocked Windows path execution in Docker:', slicer.path); throw new Error('Cannot execute Windows executable in Docker container. Please use a Linux-compatible slicer path.'); } - - let command; - if (process.platform === 'darwin' && slicer.path.toLowerCase().endsWith('.app')) { - command = `open -a "${slicer.path}" --args "${modelPath}"`; - } else { - command = `"${slicer.path}" "${modelPath}"`; - } - - exec(command, (error, stdout, stderr) => { - if (error) { - console.error('Error executing slicer command:', error); - const win = getWindowFromEvent(event); - if (win && !win.isDestroyed()) { - dialog.showErrorBox('Slice Model Error', error.message); - } - } - }); + + await runSlicerWithModelPaths(slicer, [modelPath]); } catch (error) { console.error('Error slicing model:', error); const win = getWindowFromEvent(event); @@ -6885,6 +6929,97 @@ async function extractModelFromZip(zipPath, entryPath, destinationPath = null) { } } +async function resolveModelPathsForSlicer(filePaths) { + const rawPaths = (Array.isArray(filePaths) ? filePaths : [filePaths]).filter(Boolean); + const resolved = []; + + for (const fp of rawPaths) { + if (typeof fp !== 'string' || isUrlModel(fp)) continue; + + const pathInfo = parseZipPath(fp); + if (pathInfo.isZipEntry) { + if (isMacOsResourceForkEntry(pathInfo.entryPath)) continue; + resolved.push(await extractModelFromZip(pathInfo.zipPath, pathInfo.entryPath)); + } else if (fs.existsSync(fp)) { + resolved.push(fp); + } + } + + return resolved; +} + +function getSlicerBySelection(slicers, { slicerId, slicerName } = {}) { + if (!Array.isArray(slicers) || slicers.length === 0) return null; + if (slicerId != null) { + return slicers.find((slicer) => slicer.id === slicerId) || null; + } + if (slicerName) { + return slicers.find((slicer) => slicer.name === slicerName) || null; + } + return slicers[0]; +} + +function escapeShellArg(filePath) { + return `"${String(filePath).replace(/"/g, '\\"')}"`; +} + +function getDarwinAppBundlePath(slicerPath) { + if (!slicerPath || process.platform !== 'darwin') return null; + const normalized = String(slicerPath).replace(/\\/g, '/'); + if (/\.app$/i.test(normalized)) return normalized; + const match = normalized.match(/^(.*?\.app)\//i); + return match ? match[1] : null; +} + +function isPrusaFamilySlicer(slicerPath) { + const base = path.basename(String(slicerPath)).toLowerCase(); + return /bambu|orca|prusa|superslicer|slic3r/.test(base); +} + +function buildSlicerLaunchCommand(slicerPath, modelPaths) { + const paths = (Array.isArray(modelPaths) ? modelPaths : [modelPaths]).filter(Boolean); + if (!paths.length) { + throw new Error('No model files to open in slicer'); + } + + const escapedPaths = paths.map(escapeShellArg).join(' '); + const appBundle = getDarwinAppBundlePath(slicerPath); + if (appBundle) { + // -n opens a new instance even when the slicer is already running (macOS). + return `open -n -a ${escapeShellArg(appBundle)} --args ${escapedPaths}`; + } + + let command = escapeShellArg(slicerPath); + if (isPrusaFamilySlicer(slicerPath)) { + command += ' --single-instance=0'; + } + return `${command} ${escapedPaths}`; +} + +function runSlicerWithModelPaths(slicer, modelPaths) { + if (!modelPaths.length) { + return Promise.reject(new Error('No model files to open in slicer')); + } + + const inDocker = isDockerContainer(); + if (inDocker && (/^[A-Za-z]:[\\/]/.test(slicer.path) || /^\\\\/.test(slicer.path))) { + return Promise.reject(new Error( + `The slicer path "${slicer.path}" is a Windows path, but the application is running in a Docker container (Linux). ` + + 'Use a Linux slicer path or run Printventory in normal mode.' + )); + } + + const { exec } = require('child_process'); + const command = buildSlicerLaunchCommand(slicer.path, modelPaths); + + return new Promise((resolve, reject) => { + exec(command, (error) => { + if (error) reject(error); + else resolve({ success: true, count: modelPaths.length }); + }); + }); +} + // Helper function to clean HTML entities and special characters from description text function cleanDescriptionText(text) { if (!text) return text; @@ -8265,10 +8400,19 @@ ipcMain.handle('add-multiple-thumbnails', async (event, filePath, imageDataUrls) const fileName = path.basename(filePath); // Create model entry const dateAdded = new Date().toISOString(); + const bundle = deriveBundleFromFilePath(filePath); db.prepare(` - INSERT INTO models (filePath, fileName, thumbnail, dateAdded, isNew) - VALUES (?, ?, ?, ?, 1) - `).run(filePath, fileName, '', dateAdded); + INSERT INTO models (filePath, fileName, thumbnail, dateAdded, isNew, bundleKey, bundleLabel, bundleKind) + VALUES (?, ?, ?, ?, 1, ?, ?, ?) + `).run( + filePath, + fileName, + '', + dateAdded, + bundle.bundleKey || null, + bundle.bundleLabel || null, + bundle.bundleKind || null + ); // Re-fetch the model model = db.prepare('SELECT thumbnail FROM models WHERE filePath = ?').get(filePath); if (!model) { @@ -8847,7 +8991,7 @@ ipcMain.handle('get-models-with-default-thumbnails', async () => { // Add this new IPC handler to fetch models by directory ipcMain.handle('get-models-by-directory', async (event, directoryPath) => { try { - const selectCols = "id, filePath, fileName, designer, source, notes, printed, parentModel, hash, size, license, modifiedDate, dateAdded, isNew, CASE WHEN thumbnail IS NOT NULL AND thumbnail != '' AND thumbnail != '3d.png' THEN 1 ELSE 0 END AS hasThumbnail"; + const selectCols = "id, filePath, fileName, designer, source, notes, printed, parentModel, hash, size, license, modifiedDate, dateAdded, isNew, bundleKey, bundleLabel, bundleKind, CASE WHEN thumbnail IS NOT NULL AND thumbnail != '' AND thumbnail != '3d.png' THEN 1 ELSE 0 END AS hasThumbnail"; const models = db.prepare(` SELECT ${selectCols} FROM models WHERE REPLACE(LOWER(filePath), CHAR(92), '/') LIKE ? @@ -8863,7 +9007,7 @@ ipcMain.handle('get-models-by-directory', async (event, directoryPath) => { ipcMain.handle('get-models-page', async (event, { page, pageSize, sortOption }) => { try { const offset = (page - 1) * pageSize; - const selectCols = "id, filePath, fileName, designer, source, notes, printed, parentModel, hash, size, license, modifiedDate, dateAdded, isNew, CASE WHEN thumbnail IS NOT NULL AND thumbnail != '' AND thumbnail != '3d.png' THEN 1 ELSE 0 END AS hasThumbnail"; + const selectCols = "id, filePath, fileName, designer, source, notes, printed, parentModel, hash, size, license, modifiedDate, dateAdded, isNew, bundleKey, bundleLabel, bundleKind, CASE WHEN thumbnail IS NOT NULL AND thumbnail != '' AND thumbnail != '3d.png' THEN 1 ELSE 0 END AS hasThumbnail"; const models = db.prepare( `SELECT ${selectCols} FROM models ORDER BY ${sortOption} LIMIT ? OFFSET ?` ).all(pageSize, offset); @@ -9051,6 +9195,62 @@ ipcMain.handle('clear-and-save-slicers', clearAndSaveSlicersHandler); // Register in handler registry for WebSocket/Server mode ipcHandlerRegistry.set('clear-and-save-slicers', clearAndSaveSlicersHandler); +const openFileInSlicerHandler = async (event, options = {}) => { + const { filePaths, slicerId, slicerName } = options || {}; + const paths = Array.isArray(filePaths) ? filePaths : (filePaths ? [filePaths] : []); + if (!paths.length) { + throw new Error('No file paths provided'); + } + + ensureSlicersTableExists(); + const slicers = db.prepare('SELECT * FROM slicers').all(); + const slicer = getSlicerBySelection(slicers, { slicerId, slicerName }); + if (!slicer) { + throw new Error('No slicer configured. Add a slicer in Settings.'); + } + + if (isServerMode) { + const firstPath = paths[0]; + const pathInfo = parseZipPath(firstPath); + const commandPayload = { + type: 'open-in-slicer', + filePaths: paths, + filePath: firstPath, + slicerName: slicer.name, + slicerPath: slicer.path, + isZipEntry: pathInfo.isZipEntry, + zipPath: pathInfo.isZipEntry ? pathInfo.zipPath : null, + entryPath: pathInfo.isZipEntry ? pathInfo.entryPath : null + }; + + if (global.broadcastEvent) { + global.broadcastEvent('execute-client-command', commandPayload); + } else { + event.sender.send('execute-client-command', commandPayload); + } + return { success: true, serverMode: true, count: paths.length }; + } + + const modelPaths = await resolveModelPathsForSlicer(paths); + if (!modelPaths.length) { + throw new Error('No valid local model files to open in slicer'); + } + + try { + return await runSlicerWithModelPaths(slicer, modelPaths); + } catch (error) { + console.error('Error opening file in slicer:', error); + const win = getWindowFromEvent(event); + if (win && !win.isDestroyed()) { + dialog.showErrorBox('Send to Slicer', error.message); + } + throw error; + } +}; + +ipcMain.handle('open-file-in-slicer', openFileInSlicerHandler); +ipcHandlerRegistry.set('open-file-in-slicer', openFileInSlicerHandler); + ipcMain.handle('get-file-stats', async (event, filePath) => { try { const stats = await fs.promises.stat(filePath); @@ -9081,47 +9281,40 @@ const executeClientCommandHandler = async (event, commandData) => { } return { success: true }; } else if (type === 'open-in-slicer') { - // Execute slicer command on client machine - const { exec } = require('child_process'); - let modelPath = filePath; - - // Handle zip entries - would need extraction, but for now just use zip path - if (isZipEntry && zipPath && entryPath) { - // For zip entries, we can't easily pass the entry to the slicer - // Show a message instead + const rawPaths = Array.isArray(commandData.filePaths) && commandData.filePaths.length + ? commandData.filePaths + : (filePath ? [filePath] : []); + + let modelPaths = []; + try { + modelPaths = await resolveModelPathsForSlicer(rawPaths); + } catch (error) { + return { success: false, error: error.message }; + } + + if (!modelPaths.length) { const win = getWindowFromEvent(event); + const detail = isZipEntry && zipPath && entryPath + ? `To open ${entryPath} from ${zipPath}:\n\n1. Extract ${entryPath} from the ZIP file\n2. Open the extracted file in ${slicerName}` + : `Could not resolve local model paths for the slicer.`; if (win && !win.isDestroyed()) { dialog.showMessageBox(win, { type: 'info', - title: 'ZIP Entry', - message: 'Cannot open ZIP entry directly in slicer', - detail: `To open ${entryPath} from ${zipPath}:\n\n` + - `1. Extract ${entryPath} from the ZIP file\n` + - `2. Open the extracted file in ${slicerName}` + title: 'Send to Slicer', + message: 'Cannot open these models in slicer from here', + detail }); } - return { success: false, message: 'ZIP entries require extraction first' }; + return { success: false, message: 'No resolvable model paths' }; } - - // Construct command based on platform - let command; - if (process.platform === 'darwin' && slicerPath.toLowerCase().endsWith('.app')) { - command = `open -a "${slicerPath}" --args "${modelPath}"`; - } else { - command = `"${slicerPath}" "${modelPath}"`; + + try { + await runSlicerWithModelPaths({ name: slicerName, path: slicerPath }, modelPaths); + return { success: true, count: modelPaths.length }; + } catch (error) { + console.error('Error executing slicer command on client:', error); + return { success: false, error: error.message }; } - - return new Promise((resolve) => { - exec(command, (error, stdout, stderr) => { - if (error) { - console.error('Error executing slicer command on client:', error); - resolve({ success: false, error: error.message }); - } else { - console.log('Successfully executed slicer command on client'); - resolve({ success: true }); - } - }); - }); } return { success: false, error: 'Unknown command type' }; @@ -9549,6 +9742,7 @@ async function saveModel(modelData) { const finalPrinted = printed !== undefined ? (printed ? 1 : 0) : existingModelData.printed; const finalParentModel = parentModel !== undefined ? (parentModel || null) : existingModelData.parentModel; const finalLicense = license !== undefined ? (license || null) : existingModelData.license; + const bundle = deriveBundleFromFilePath(filePath); // Use a simpler update approach to avoid foreign key issues const updateStmt = db.prepare(` @@ -9560,6 +9754,9 @@ async function saveModel(modelData) { printed = ?, parentModel = ?, license = ?, + bundleKey = ?, + bundleLabel = ?, + bundleKind = ?, isNew = 0 WHERE id = ? `); @@ -9572,6 +9769,9 @@ async function saveModel(modelData) { finalPrinted, finalParentModel, finalLicense, + bundle.bundleKey || null, + bundle.bundleLabel || null, + bundle.bundleKind || null, existingModel.id ); @@ -9581,10 +9781,12 @@ async function saveModel(modelData) { console.log('Inserting new model'); const dateAdded = new Date().toISOString(); + const bundle = deriveBundleFromFilePath(filePath); const insertStmt = db.prepare(` INSERT INTO models ( - filePath, fileName, designer, source, notes, printed, parentModel, license, dateAdded, isNew - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1) + filePath, fileName, designer, source, notes, printed, parentModel, license, + dateAdded, isNew, bundleKey, bundleLabel, bundleKind + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?) `); const result = insertStmt.run( @@ -9596,7 +9798,10 @@ async function saveModel(modelData) { printed ? 1 : 0, parentModel || null, license || null, - dateAdded + dateAdded, + bundle.bundleKey || null, + bundle.bundleLabel || null, + bundle.bundleKind || null ); modelId = result.lastInsertRowid; diff --git a/package-lock.json b/package-lock.json index e4a4975..981ba30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "printventory", - "version": "2.1.3", + "version": "2.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "printventory", - "version": "2.1.3", + "version": "2.1.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 19689df..4e38a7b 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "main.js", "scripts": { "test": "playwright test", + "test:bundle": "node bundle-keys.test.js", "start": "electron .", "start:server": "electron . --server", "build": "electron-builder -mw", @@ -102,6 +103,7 @@ }, "files": [ "main.js", + "bundle-keys.js", "preload.js", "renderer.js", "index.html", diff --git a/preload.js b/preload.js index 8432676..86709ac 100644 --- a/preload.js +++ b/preload.js @@ -288,6 +288,7 @@ contextBridge.exposeInMainWorld('electron', { fetchMakerWorldPage: (url) => ipcRenderer.invoke('fetch-makerworld-page', url), onOpenSlicerSettings: (callback) => ipcRenderer.on('open-slicer-settings', callback), getSlicers: () => ipcRenderer.invoke('get-slicers'), + openFileInSlicer: (options) => ipcRenderer.invoke('open-file-in-slicer', options), saveSlicer: (slicer) => ipcRenderer.invoke('save-slicer', slicer), deleteSlicer: (id) => ipcRenderer.invoke('delete-slicer', id), clearAndSaveSlicers: (slicers) => ipcRenderer.invoke('clear-and-save-slicers', slicers), diff --git a/preview.js b/preview.js index 794d38b..36f765f 100644 --- a/preview.js +++ b/preview.js @@ -12,6 +12,7 @@ console.log('[Preview] preview.js script loaded'); let currentFilePath = null; let previewLoadToken = 0; let preview3mfRequestId = null; + let currentBundleGroupRecord = null; // Register preview-model listener (server mode WebSocket and normal IPC both dispatch here) const previewCallback = (filePath) => { @@ -85,6 +86,20 @@ console.log('[Preview] preview.js script loaded'); toggleAxes(); }); + const slicerBtn = document.getElementById('preview-send-to-slicer'); + if (slicerBtn) { + slicerBtn.addEventListener('click', () => { + handlePreviewSendToSlicer(); + }); + } + + document.addEventListener('click', (event) => { + const menu = document.getElementById('preview-slicer-menu'); + if (!menu || menu.classList.contains('hidden')) return; + if (event.target.closest('#preview-slicer-menu') || event.target.closest('#preview-send-to-slicer')) return; + hidePreviewSlicerMenu(); + }); + // Close on backdrop click dialog.addEventListener('click', (e) => { if (e.target === dialog) { @@ -113,6 +128,8 @@ console.log('[Preview] preview.js script loaded'); async function openPreview(filePath) { console.log('[Preview] openPreview called with:', filePath); currentFilePath = filePath; + currentBundleGroupRecord = null; + hidePreviewSlicerMenu(); const loadToken = ++previewLoadToken; const dialog = document.getElementById('preview-dialog'); @@ -133,6 +150,7 @@ console.log('[Preview] preview.js script loaded'); loading.style.display = 'flex'; fileType.textContent = ''; dimensions.textContent = ''; + updatePreviewSlicerButton(); // Open dialog dialog.showModal(); @@ -149,6 +167,7 @@ console.log('[Preview] preview.js script loaded'); await loadPreviewModel(filePath, loadToken); console.log('Model loaded successfully'); loading.style.display = 'none'; + updatePreviewSlicerButton(); } catch (error) { const message = error && error.message ? error.message : ''; if (message === 'Preview cancelled' || message.includes('Preview cancelled')) { @@ -172,7 +191,8 @@ console.log('[Preview] preview.js script loaded'); // Exposed for debugging and any late-loaded code; server bridge no longer calls this directly window.openPreview = openPreview; - console.log('[Preview] Exposed window.openPreview globally'); + window.openBundlePreview = openBundlePreview; + console.log('[Preview] Exposed window.openPreview and window.openBundlePreview globally'); // Initialize Three.js scene function initPreviewScene() { @@ -445,138 +465,398 @@ console.log('[Preview] preview.js script loaded'); }); } - // Load preview model - async function loadPreviewModel(filePath, loadToken) { - return new Promise(async (resolve, reject) => { + function getPreviewExtension(filePath) { + const pathForExt = filePath.includes('::') ? (filePath.split('::')[1] || '') : filePath; + return pathForExt.split('.').pop().toLowerCase(); + } + + function isPreviewableModelPath(filePath) { + const ext = getPreviewExtension(filePath); + return ext === 'stl' || ext === '3mf'; + } + + function applyPartTint(object, index, total) { + if (total <= 1) return; + const hue = (index / total) * 0.75 + 0.05; + const tint = new THREE.Color().setHSL(hue, 0.55, 0.52); + object.traverse((child) => { + if (!child.isMesh) return; + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((mat, matIndex) => { + if (!mat || mat.map || mat.vertexColors) return; + const tinted = mat.clone(); + tinted.color = tint.clone(); + if (Array.isArray(child.material)) { + child.material[matIndex] = tinted; + } else { + child.material = tinted; + } + }); + }); + } + + async function createPreviewObjectFromPath(filePath, loadToken) { + if (loadToken !== previewLoadToken) { + throw new Error('Preview cancelled'); + } + + const ext = getPreviewExtension(filePath); + if (ext !== 'stl' && ext !== '3mf') { + throw new Error(`Unsupported file type: ${ext}`); + } + + if (ext === 'stl') { + if (!THREE.STLLoader) { + throw new Error('STLLoader not available'); + } + + const loader = new THREE.STLLoader(); + const arrayBuffer = await window.electron.readModelFile(filePath); if (loadToken !== previewLoadToken) { - reject(new Error('Preview cancelled')); - return; + throw new Error('Preview cancelled'); } - const pathForExt = filePath.includes('::') ? (filePath.split('::')[1] || '') : filePath; - const ext = pathForExt.split('.').pop().toLowerCase(); - const fileType = document.getElementById('preview-file-type'); - - fileType.textContent = `Type: ${ext.toUpperCase()}`; - console.log('Loading model type:', ext); - // Only STL and 3MF support 3D preview; show message for other types - if (ext !== 'stl' && ext !== '3mf') { - reject(new Error('Preview not available for this file type. Only STL and 3MF models can be previewed in 3D.')); - return; + validateSTLBuffer(arrayBuffer); + const geometry = loader.parse(arrayBuffer); + if (!geometry) { + throw new Error('Failed to parse STL geometry'); } - try { - if (ext === 'stl') { - // Load STL - if (!THREE.STLLoader) { - throw new Error('STLLoader not available'); - } - - console.log('Creating STL loader...'); - const loader = new THREE.STLLoader(); - - // Read file data as ArrayBuffer - console.log('Reading STL file...'); - const arrayBuffer = await window.electron.readModelFile(filePath); - if (loadToken !== previewLoadToken) { - reject(new Error('Preview cancelled')); - return; - } - console.log('STL file read, size:', arrayBuffer.byteLength, 'bytes'); - validateSTLBuffer(arrayBuffer); - // Parse the STL data - console.log('Parsing STL data...'); - const geometry = loader.parse(arrayBuffer); - console.log('STL parsed, geometry:', geometry); - - if (!geometry) { - throw new Error('Failed to parse STL geometry'); - } + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + geometry.computeBoundingSphere(); + + const material = new THREE.MeshStandardMaterial({ + color: 0x4a9eff, + metalness: 0.3, + roughness: 0.6, + flatShading: false, + emissive: 0x002244, + emissiveIntensity: 0.2 + }); - // Compute normals and bounding box - geometry.computeVertexNormals(); - geometry.computeBoundingBox(); - geometry.computeBoundingSphere(); - console.log('Geometry computed - vertices:', geometry.attributes.position.count); - console.log('Geometry bounding box:', geometry.boundingBox); + return new THREE.Mesh(geometry, material); + } - // Create mesh with material - const material = new THREE.MeshStandardMaterial({ - color: 0x4a9eff, - metalness: 0.3, - roughness: 0.6, - flatShading: false, - emissive: 0x002244, - emissiveIntensity: 0.2 - }); + const loading = document.getElementById('preview-loading'); + if (loading && loading.querySelector('p')) { + loading.querySelector('p').textContent = + 'Loading 3MF file...\nThis could take time for larger files.'; + } - previewModel = new THREE.Mesh(geometry, material); - previewScene.add(previewModel); - console.log('STL mesh added to scene, position:', previewModel.position); - console.log('Mesh in scene:', previewScene.children.includes(previewModel)); - - // Center and scale the model - centerAndScaleModel(previewModel); - updateModelDimensions(previewModel); - - resolve(); - - } else if (ext === '3mf') { - // Load 3MF via main-process worker for responsiveness - const loading = document.getElementById('preview-loading'); - if (loading && loading.querySelector('p')) { - loading.querySelector('p').textContent = - 'Loading 3MF file...\nThis could take time for larger files.'; - } + preview3mfRequestId = `${Date.now()}_${Math.random().toString(16).slice(2)}`; + const json = await window.electron.parse3MFPreview(filePath, preview3mfRequestId); + if (loadToken !== previewLoadToken) { + throw new Error('Preview cancelled'); + } - preview3mfRequestId = `${Date.now()}_${Math.random().toString(16).slice(2)}`; - const json = await window.electron.parse3MFPreview(filePath, preview3mfRequestId); - if (loadToken !== previewLoadToken) { - reject(new Error('Preview cancelled')); - return; - } + if (!json) { + throw new Error('Failed to load 3MF file'); + } - if (!json) { - throw new Error('Failed to load 3MF file'); - } + const objectLoader = new THREE.ObjectLoader(); + const object = objectLoader.parse(json); + if (!object) { + throw new Error('Failed to parse 3MF preview'); + } - const objectLoader = new THREE.ObjectLoader(); - const object = objectLoader.parse(json); - if (!object) { - throw new Error('Failed to parse 3MF preview'); - } + object.traverse((child) => { + if (child.isMesh && child.geometry) { + if (!child.geometry.attributes.normal || child.geometry.attributes.normal.count === 0) { + child.geometry.computeVertexNormals(); + } + } + }); - // Ensure geometry has normals computed - object.traverse((child) => { - if (child.isMesh && child.geometry) { - if (!child.geometry.attributes.normal || child.geometry.attributes.normal.count === 0) { - child.geometry.computeVertexNormals(); - } - } - }); + if (!hasColorData(object)) { + applyDefaultMetalMaterial(object); + } else { + ensureLitMaterials(object); + } - if (!hasColorData(object)) { - applyDefaultMetalMaterial(object); - } else { - ensureLitMaterials(object); - } + return object; + } - previewModel = object; - previewScene.add(previewModel); + // Load preview model + async function loadPreviewModel(filePath, loadToken) { + const ext = getPreviewExtension(filePath); + const fileType = document.getElementById('preview-file-type'); - centerAndScaleModel(previewModel); - updateModelDimensions(previewModel); + fileType.textContent = `Type: ${ext.toUpperCase()}`; + console.log('Loading model type:', ext); - resolve(); + if (ext !== 'stl' && ext !== '3mf') { + throw new Error('Preview not available for this file type. Only STL and 3MF models can be previewed in 3D.'); + } - } else { - throw new Error(`Unsupported file type: ${ext}`); - } + const object = await createPreviewObjectFromPath(filePath, loadToken); + previewModel = object; + previewScene.add(previewModel); + centerAndScaleModel(previewModel); + updateModelDimensions(previewModel); + } + + const MAX_BUNDLE_PREVIEW_PARTS = 32; + + async function openBundlePreview(groupRecord) { + const children = groupRecord?.children || []; + const previewable = children.filter((child) => child?.filePath && isPreviewableModelPath(child.filePath)); + if (!previewable.length) { + alert('No STL or 3MF models in this bundle to preview.'); + return; + } + + const sorted = [...previewable].sort((a, b) => + String(a.fileName || '').localeCompare(String(b.fileName || ''), undefined, { sensitivity: 'base' }) + ); + const toLoad = sorted.slice(0, MAX_BUNDLE_PREVIEW_PARTS); + const truncated = previewable.length > MAX_BUNDLE_PREVIEW_PARTS; + + currentFilePath = null; + const loadToken = ++previewLoadToken; + const dialog = document.getElementById('preview-dialog'); + if (!dialog) { + console.error('[Preview] preview-dialog element not found!'); + return; + } + + currentBundleGroupRecord = groupRecord; + hidePreviewSlicerMenu(); + const modelName = document.getElementById('preview-model-name'); + const loading = document.getElementById('preview-loading'); + const fileType = document.getElementById('preview-file-type'); + const dimensions = document.getElementById('preview-dimensions'); + const groupLabel = groupRecord.groupLabel || 'Bundle'; + + modelName.textContent = groupLabel; + loading.style.display = 'flex'; + if (loading.querySelector('p')) { + loading.querySelector('p').textContent = `Loading bundle preview (0/${toLoad.length})...`; + } + fileType.textContent = ''; + dimensions.textContent = ''; + updatePreviewSlicerButton(); + + dialog.showModal(); + await new Promise((resolve) => setTimeout(resolve, 100)); + initPreviewScene(); + + const root = new THREE.Group(); + const placed = []; + let loadFailures = 0; + + for (let i = 0; i < toLoad.length; i++) { + const child = toLoad[i]; + const loadingText = loading.querySelector('p'); + if (loadingText) { + loadingText.textContent = + `Loading bundle preview (${i + 1}/${toLoad.length})...\n${child.fileName || ''}`; + } + + try { + const obj = await createPreviewObjectFromPath(child.filePath, loadToken); + applyPartTint(obj, i, toLoad.length); + + const box = new THREE.Box3().setFromObject(obj); + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + obj.position.sub(center); + placed.push({ obj, size }); } catch (error) { - reject(error); + const message = error && error.message ? error.message : ''; + if (message === 'Preview cancelled' || message.includes('Preview cancelled')) { + return; + } + console.warn('Bundle preview skipped:', child.filePath, error); + loadFailures++; } + } + + if (loadToken !== previewLoadToken) return; + + if (!placed.length) { + loading.innerHTML = ` +
    +

    Could not load bundle preview

    +

    No models in this bundle could be loaded for 3D preview.

    + +
    + `; + return; + } + + const maxPartDim = Math.max( + ...placed.map((entry) => Math.max(entry.size.x, entry.size.y, entry.size.z)), + 1 + ); + const cellSpacing = maxPartDim * 1.4; + const cols = Math.ceil(Math.sqrt(placed.length)); + + placed.forEach((entry, index) => { + const col = index % cols; + const row = Math.floor(index / cols); + entry.obj.position.x = col * cellSpacing; + entry.obj.position.z = -row * cellSpacing; + root.add(entry.obj); + }); + + previewModel = root; + previewScene.add(previewModel); + centerAndScaleModel(previewModel); + + const bundleKind = groupRecord.children?.[0]?.bundleKind === 'zip' ? 'ZIP' : 'Folder'; + fileType.textContent = `${bundleKind} bundle • ${placed.length} model${placed.length === 1 ? '' : 's'}`; + + const dimParts = []; + if (truncated) { + dimParts.push(`Showing first ${MAX_BUNDLE_PREVIEW_PARTS} of ${previewable.length} previewable models`); + } + if (loadFailures) { + dimParts.push(`${loadFailures} model${loadFailures === 1 ? '' : 's'} failed to load`); + } + dimensions.textContent = dimParts.join(' • '); + + loading.style.display = 'none'; + updatePreviewSlicerButton(); + } + + function getPreviewSlicerFilePaths() { + if (currentFilePath && !currentFilePath.startsWith('url::')) { + return [currentFilePath]; + } + if (currentBundleGroupRecord?.children?.length) { + return currentBundleGroupRecord.children + .map((child) => child?.filePath) + .filter((filePath) => filePath && isPreviewableModelPath(filePath) && !filePath.startsWith('url::')); + } + return []; + } + + function updatePreviewSlicerButton() { + const button = document.getElementById('preview-send-to-slicer'); + if (!button) return; + + const paths = getPreviewSlicerFilePaths(); + button.disabled = paths.length === 0; + if (paths.length === 0) { + button.title = 'No local model to send to slicer'; + } else if (paths.length === 1) { + button.title = 'Open this model in your slicer'; + } else { + button.title = `Open ${paths.length} models in your slicer`; + } + } + + function hidePreviewSlicerMenu() { + const menu = document.getElementById('preview-slicer-menu'); + if (!menu) return; + menu.classList.add('hidden'); + menu.innerHTML = ''; + } + + function showPreviewSlicerMenu(slicers, filePaths) { + const menu = document.getElementById('preview-slicer-menu'); + if (!menu) return; + + menu.innerHTML = ''; + slicers.forEach((slicer) => { + const item = document.createElement('button'); + item.type = 'button'; + item.className = 'preview-slicer-menu-item'; + item.textContent = slicer.name; + item.title = slicer.path || slicer.name; + item.addEventListener('click', async (event) => { + event.preventDefault(); + event.stopPropagation(); + hidePreviewSlicerMenu(); + await sendPreviewToSlicer(slicer, filePaths); + }); + menu.appendChild(item); }); + menu.classList.remove('hidden'); + } + + async function sendPreviewToSlicer(slicer, filePaths) { + if (!window.electron?.openFileInSlicer) { + alert('Send to slicer is not available in this mode.'); + return; + } + + try { + const result = await window.electron.openFileInSlicer({ + filePaths, + slicerId: slicer.id, + slicerName: slicer.name + }); + if (result?.success) { + console.log('[Preview] Sent to slicer:', slicer.name, result); + } + } catch (error) { + const message = error && error.message ? error.message : String(error); + alert(`Could not send to slicer:\n${message}`); + } + } + + async function loadConfiguredSlicers() { + let slicers = []; + try { + if (typeof window.electron?.getSlicers === 'function') { + slicers = await window.electron.getSlicers(); + } + } catch (error) { + console.error('[Preview] Error loading slicers:', error); + } + + if (Array.isArray(slicers) && slicers.length === 1 && Array.isArray(slicers[0])) { + slicers = slicers[0]; + } + + slicers = (Array.isArray(slicers) ? slicers : []).filter( + (slicer) => slicer && slicer.name && slicer.path + ); + + if (slicers.length) return slicers; + + try { + const legacyPath = await window.electron?.getSetting?.('slicerPath'); + if (legacyPath) { + return [{ id: null, name: 'Slicer', path: legacyPath }]; + } + } catch (error) { + console.error('[Preview] Error loading legacy slicer path:', error); + } + + return []; + } + + async function handlePreviewSendToSlicer() { + const filePaths = getPreviewSlicerFilePaths(); + if (!filePaths.length) return; + + hidePreviewSlicerMenu(); + + const slicers = await loadConfiguredSlicers(); + + if (!slicers.length) { + const configure = confirm('No slicer configured. Open Slicer Settings now?'); + if (configure && typeof window.openSlicerSettings === 'function') { + await window.openSlicerSettings(); + } + return; + } + + if (slicers.length === 1) { + await sendPreviewToSlicer(slicers[0], filePaths); + return; + } + + showPreviewSlicerMenu(slicers, filePaths); } // Center and scale model to fit in view @@ -774,6 +1054,9 @@ console.log('[Preview] preview.js script loaded'); cleanupPreviewScene(); dialog.close(); currentFilePath = null; + currentBundleGroupRecord = null; + hidePreviewSlicerMenu(); + updatePreviewSlicerButton(); } // Initialize when DOM is ready diff --git a/renderer.js b/renderer.js index 4f252fa..e8944ef 100644 --- a/renderer.js +++ b/renderer.js @@ -1403,6 +1403,59 @@ function pruneZipArchiveExpandedGroups(models) { } } +function deriveBundleFieldsForModel(model) { + const filePath = model?.filePath || ''; + if (!filePath || filePath.startsWith('url::')) { + return { bundleKey: '', bundleLabel: '', bundleKind: '' }; + } + if (filePath.includes('::')) { + const zipPath = filePath.split('::')[0]; + const normalized = normalizePathForComparison(zipPath).toLowerCase(); + const parts = normalized.split('/').filter(Boolean); + const label = parts.length ? parts[parts.length - 1] : zipPath; + return { bundleKey: `zip:${normalized}`, bundleLabel: label, bundleKind: 'zip' }; + } + const normalized = normalizePathForComparison(filePath); + const parts = normalized.split('/').filter(Boolean); + if (parts.length < 2) { + return { bundleKey: '', bundleLabel: '', bundleKind: '' }; + } + parts.pop(); + const dir = parts.join('/').toLowerCase(); + const label = parts[parts.length - 1] || dir; + return { bundleKey: `folder:${dir}`, bundleLabel: label, bundleKind: 'folder' }; +} + +function getBundleGroupLabel(model) { + if (model?.bundleLabel) return String(model.bundleLabel).trim(); + return deriveBundleFieldsForModel(model).bundleLabel; +} + +function getBundleGroupKey(model) { + if (model?.bundleKey) return String(model.bundleKey).trim().toLowerCase(); + return deriveBundleFieldsForModel(model).bundleKey; +} + +function pruneBundleExpandedGroups(models) { + if (!bundleExpandedGroups.size) return; + const pathSets = new Map(); + for (const model of models || []) { + const bundleKey = getBundleGroupKey(model); + if (!bundleKey) continue; + const gk = `bundle:${bundleKey}`; + const pathKey = normalizePathForComparison(model.filePath || ''); + if (!pathKey) continue; + if (!pathSets.has(gk)) pathSets.set(gk, new Set()); + pathSets.get(gk).add(pathKey); + } + for (const key of [...bundleExpandedGroups]) { + const set = pathSets.get(key); + if (!set || set.size < 2) { + bundleExpandedGroups.delete(key); + } + } +} + /** Merge fresh model into virtual grid's currentModels when paths match (normalized). */ function mergeModelIntoGridCurrentModels(model) { const container = document.querySelector('.file-grid'); @@ -2389,6 +2442,8 @@ async function showModelDetails(filePath) { if (!model) return; + hideBundleDetailsPanel(); + // Get the details panel reference const detailsPanel = document.getElementById('model-details'); if (!detailsPanel) { @@ -10693,10 +10748,14 @@ document.addEventListener('DOMContentLoaded', async () => { if (hasNodeAccess) { try { const { exec } = require('child_process'); - let modelPath = filePath; - - // Handle zip entries - for now show instructions (would need extraction) - if (isZipEntry && zipPath && entryPath) { + const rawPaths = Array.isArray(commandData.filePaths) && commandData.filePaths.length + ? commandData.filePaths + : [filePath]; + const modelPaths = isZipEntry && zipPath && entryPath && rawPaths.length === 1 + ? null + : rawPaths.filter(Boolean); + + if (!modelPaths) { alert(`To open a file from a ZIP archive in your slicer:\n\n` + `1. Download the ZIP file: ${zipPath}\n` + `2. Extract ${entryPath} from the ZIP\n` + @@ -10704,20 +10763,14 @@ document.addEventListener('DOMContentLoaded', async () => { `Slicer: ${slicerPath}`); return; } - - // Construct command based on platform - let command; - if (process.platform === 'darwin' && slicerPath.toLowerCase().endsWith('.app')) { - command = `open -a "${slicerPath}" --args "${modelPath}"`; - } else { - command = `"${slicerPath}" "${modelPath}"`; - } + + const command = buildSlicerLaunchCommand(slicerPath, modelPaths); exec(command, (error, stdout, stderr) => { if (error) { console.error('Error executing slicer on client:', error); alert(`Error opening file in ${slicerName}:\n${error.message}\n\n` + - `File: ${modelPath}\n` + + `File(s): ${modelPaths.join(', ')}\n` + `Slicer: ${slicerPath}\n\n` + `Please try opening the file manually.`); } else { @@ -10743,6 +10796,37 @@ document.addEventListener('DOMContentLoaded', async () => { } }); + function escapeSlicerShellArg(filePath) { + return `"${String(filePath).replace(/"/g, '\\"')}"`; + } + + function getDarwinSlicerAppBundlePath(slicerPath) { + if (!slicerPath || process.platform !== 'darwin') return null; + const normalized = String(slicerPath).replace(/\\/g, '/'); + if (/\.app$/i.test(normalized)) return normalized; + const match = normalized.match(/^(.*?\.app)\//i); + return match ? match[1] : null; + } + + function isPrusaFamilySlicerPath(slicerPath) { + const base = String(slicerPath).split(/[/\\]/).pop().toLowerCase(); + return /bambu|orca|prusa|superslicer|slic3r/.test(base); + } + + function buildSlicerLaunchCommand(slicerPath, modelPaths) { + const paths = (Array.isArray(modelPaths) ? modelPaths : [modelPaths]).filter(Boolean); + const escapedPaths = paths.map(escapeSlicerShellArg).join(' '); + const appBundle = getDarwinSlicerAppBundlePath(slicerPath); + if (appBundle) { + return `open -n -a ${escapeSlicerShellArg(appBundle)} --args ${escapedPaths}`; + } + let command = escapeSlicerShellArg(slicerPath); + if (isPrusaFamilySlicerPath(slicerPath)) { + command += ' --single-instance=0'; + } + return `${command} ${escapedPaths}`; + } + // Helper function to show slicer instructions function showSlicerInstructions(filePath, slicerName, slicerPath, isZipEntry, zipPath, entryPath) { let message = `To open this file in ${slicerName}:\n\n`; @@ -18987,6 +19071,7 @@ function isProgressiveModelListExtension(prevModels, nextModels) { const parentModelExpandedGroups = new Set(); const zipArchiveExpandedGroups = new Set(); +const bundleExpandedGroups = new Set(); let groupThumbnailPreferencesLoaded = false; let groupThumbnailPreferencesLoading = null; const groupThumbnailPreferences = {}; @@ -19165,15 +19250,15 @@ function buildParentModelDisplayRecords(models) { }); }); - const zipGroupedRecords = buildGroupedDisplayRecords(records, { - groupKind: 'zip', - keyPrefix: 'zip', - expandedSet: zipArchiveExpandedGroups, - getGroupLabelFromModel: getZipArchiveGroupLabel, - getGroupKeyFromModel: getZipArchiveGroupKey + const bundleGroupedRecords = buildGroupedDisplayRecords(records, { + groupKind: 'bundle', + keyPrefix: 'bundle', + expandedSet: bundleExpandedGroups, + getGroupLabelFromModel: getBundleGroupLabel, + getGroupKeyFromModel: getBundleGroupKey }); - return buildGroupedDisplayRecords(zipGroupedRecords, { + return buildGroupedDisplayRecords(bundleGroupedRecords, { groupKind: 'parentModel', keyPrefix: 'parent', expandedSet: parentModelExpandedGroups, @@ -19466,6 +19551,168 @@ async function collectThumbnailsForGroup(groupRecord) { return thumbnails; } +function getBundleContainerPath(groupRecord) { + const first = groupRecord?.children?.[0]; + if (!first?.filePath) { + return { path: '', kind: 'folder' }; + } + const bundleKind = first.bundleKind || deriveBundleFieldsForModel(first).bundleKind; + if (bundleKind === 'zip' || first.filePath.includes('::')) { + return { path: parseZipPath(first.filePath).zipPath, kind: 'zip' }; + } + const sep = Math.max(first.filePath.lastIndexOf('/'), first.filePath.lastIndexOf('\\')); + return { path: sep >= 0 ? first.filePath.slice(0, sep) : first.filePath, kind: 'folder' }; +} + +let currentBundleDetailsGroupKey = null; +let currentBundleDetailsRecord = null; + +function hideBundleDetailsPanel() { + const panel = document.getElementById('bundle-details'); + if (panel) panel.classList.add('hidden'); + currentBundleDetailsGroupKey = null; + currentBundleDetailsRecord = null; + const container = document.querySelector('.file-grid'); + if (container?.renderVisibleItemsFn) container.renderVisibleItemsFn(); +} + +async function showBundleDetails(groupRecord) { + if (!groupRecord?.children?.length) return; + + currentBundleDetailsGroupKey = groupRecord.groupKey; + currentBundleDetailsRecord = groupRecord; + + document.getElementById('model-details')?.classList.add('hidden'); + document.getElementById('multi-edit-panel')?.classList.add('hidden'); + + const panel = document.getElementById('bundle-details'); + if (!panel) return; + + const bundleKind = groupRecord.children[0]?.bundleKind + || deriveBundleFieldsForModel(groupRecord.children[0]).bundleKind + || 'folder'; + const groupLabel = groupRecord.groupLabel || 'Bundle'; + const containerInfo = getBundleContainerPath(groupRecord); + const children = [...groupRecord.children].sort((a, b) => + String(a.fileName || '').localeCompare(String(b.fileName || ''), undefined, { sensitivity: 'base' }) + ); + + const titleEl = document.getElementById('bundle-details-title'); + const subtitleEl = document.getElementById('bundle-details-subtitle'); + const pathEl = document.getElementById('bundle-details-path'); + const statsEl = document.getElementById('bundle-details-stats'); + const bodyEl = document.getElementById('bundle-contents-body'); + + const kindLabel = bundleKind === 'zip' ? 'ZIP archive' : 'Folder bundle'; + if (titleEl) titleEl.textContent = groupLabel; + if (subtitleEl) subtitleEl.textContent = `${kindLabel} • ${children.length} file${children.length === 1 ? '' : 's'}`; + + const containerPath = containerInfo.path || ''; + if (pathEl) pathEl.value = containerPath; + + const totalBytes = children.reduce((sum, c) => sum + (Number(c.size) || 0), 0); + const printedCount = children.filter((c) => Boolean(c.printed)).length; + if (statsEl) { + statsEl.innerHTML = ` + ${children.length} models + ${formatFileSize(totalBytes)} combined size + ${printedCount}/${children.length} printed + `; + } + + if (bodyEl) { + bodyEl.innerHTML = ''; + for (const child of children) { + const tr = document.createElement('tr'); + tr.className = 'bundle-contents-row'; + tr.title = 'Click for model details • double-click to preview'; + const entryName = child.filePath?.includes('::') + ? (child.filePath.split('::')[1] || child.fileName) + : child.fileName; + const cells = [ + entryName || '—', + child.size ? formatFileSize(child.size) : '—', + child.printed ? 'Yes' : 'No', + child.designer || '—', + ]; + cells.forEach((text) => { + const td = document.createElement('td'); + td.textContent = text; + tr.appendChild(td); + }); + tr.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + bodyEl.querySelectorAll('.bundle-contents-row-active').forEach((row) => { + row.classList.remove('bundle-contents-row-active'); + }); + tr.classList.add('bundle-contents-row-active'); + if (child.filePath) showModelDetails(child.filePath); + }); + tr.addEventListener('dblclick', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (child.filePath && typeof window.openPreview === 'function') { + window.openPreview(child.filePath); + } + }); + bodyEl.appendChild(tr); + } + } + + const showPathBtn = document.getElementById('bundle-details-show-path'); + if (showPathBtn) { + showPathBtn.onclick = async (e) => { + e.preventDefault(); + if (containerPath && window.electron?.showItemInFolder) { + await window.electron.showItemInFolder(containerPath); + } + }; + showPathBtn.disabled = !containerPath; + } + + const expandBtn = document.getElementById('bundle-details-expand-grid'); + if (expandBtn) { + expandBtn.onclick = (e) => { + e.preventDefault(); + const expandedSet = + groupRecord.groupKind === 'bundle' + ? bundleExpandedGroups + : groupRecord.groupKind === 'zip' + ? zipArchiveExpandedGroups + : parentModelExpandedGroups; + expandedSet.add(groupRecord.groupKey); + const grid = document.querySelector('.file-grid'); + if (grid?.renderVisibleItemsFn) grid.renderVisibleItemsFn(); + else renderVirtualGrid(grid?.currentModels || []); + }; + } + + const previewBtn = document.getElementById('bundle-details-preview'); + if (previewBtn) { + previewBtn.onclick = (e) => { + e.preventDefault(); + if (typeof window.openBundlePreview === 'function') { + window.openBundlePreview(groupRecord); + } + }; + } + + const closeBtn = document.getElementById('bundle-details-close'); + if (closeBtn) { + closeBtn.onclick = (e) => { + e.preventDefault(); + hideBundleDetailsPanel(); + }; + } + + panel.classList.remove('hidden'); + panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + + const grid = document.querySelector('.file-grid'); + if (grid?.renderVisibleItemsFn) grid.renderVisibleItemsFn(); +} + async function showManageGroupThumbnailsModal(groupRecord) { await loadGroupThumbnailPreferences(); @@ -19478,7 +19725,11 @@ async function showManageGroupThumbnailsModal(groupRecord) { const title = dialog.querySelector('h3'); const desc = dialog.querySelector('.form-group p'); const groupLabel = groupRecord?.groupLabel || groupRecord?.parentModel || 'group'; - const groupLabelType = groupRecord?.groupKind === 'zip' ? 'ZIP archive group' : 'parent group'; + const groupLabelType = groupRecord?.groupKind === 'bundle' + ? (groupRecord.children?.[0]?.bundleKind === 'zip' ? 'ZIP bundle' : 'folder bundle') + : groupRecord?.groupKind === 'zip' + ? 'ZIP archive group' + : 'parent group'; if (title) title.textContent = `Manage Group Thumbnails`; if (desc) desc.textContent = `Pick the thumbnail shown on ${groupLabelType} "${groupLabel}".`; @@ -19616,8 +19867,16 @@ async function updateGroupTags(groupRecord, mode = 'merge') { function createParentModelGroupItem(groupRecord, viewMode = null) { const view = viewMode || currentGridView; const groupLabel = groupRecord?.groupLabel || groupRecord?.parentModel || 'Group'; - const expandedSet = groupRecord?.groupKind === 'zip' ? zipArchiveExpandedGroups : parentModelExpandedGroups; - const isParentModelGroup = groupRecord?.groupKind !== 'zip'; + const expandedSet = + groupRecord?.groupKind === 'bundle' + ? bundleExpandedGroups + : groupRecord?.groupKind === 'zip' + ? zipArchiveExpandedGroups + : parentModelExpandedGroups; + const isParentModelGroup = groupRecord?.groupKind === 'parentModel'; + const bundleKind = groupRecord?.groupKind === 'bundle' + ? (groupRecord.children?.[0]?.bundleKind || 'folder') + : ''; const item = document.createElement('div'); item.className = `parent-model-group parent-model-group-${view}`; if (view !== 'list') { @@ -19626,6 +19885,9 @@ function createParentModelGroupItem(groupRecord, viewMode = null) { if (groupRecord.expanded) { item.classList.add('expanded'); } + if (currentBundleDetailsGroupKey && groupRecord.groupKey === currentBundleDetailsGroupKey) { + item.classList.add('bundle-details-active'); + } item.dataset.groupKey = groupRecord.groupKey; item.dataset.groupKind = groupRecord.groupKind || 'parentModel'; item.dataset.childCount = String(groupRecord.children.length); @@ -19648,7 +19910,9 @@ function createParentModelGroupItem(groupRecord, viewMode = null) { const groupBadge = document.createElement('div'); groupBadge.className = 'parent-model-group-corner-badge'; groupBadge.classList.add(groupRecord.expanded ? 'is-expanded' : 'is-collapsed'); - groupBadge.title = `${groupRecord?.groupKind === 'zip' ? 'ZIP archive group' : 'Parent model group'} (${groupRecord.expanded ? 'expanded' : 'collapsed'})`; + groupBadge.title = groupRecord?.groupKind === 'bundle' + ? `${bundleKind === 'zip' ? 'ZIP bundle' : 'Folder bundle'} (${groupRecord.expanded ? 'expanded' : 'collapsed'})` + : `${groupRecord?.groupKind === 'zip' ? 'ZIP archive group' : 'Parent model group'} (${groupRecord.expanded ? 'expanded' : 'collapsed'})`; for (let i = 0; i < 3; i++) { groupBadge.appendChild(document.createElement('span')); } @@ -19694,7 +19958,12 @@ function createParentModelGroupItem(groupRecord, viewMode = null) { const printedCount = groupRecord.children.filter(child => Boolean(child.printed)).length; const meta = document.createElement('div'); meta.className = 'parent-model-group-meta'; - meta.textContent = `${groupRecord.children.length} model${groupRecord.children.length === 1 ? '' : 's'} • ${printedCount}/${groupRecord.children.length} printed`; + if (groupRecord?.groupKind === 'bundle') { + const kindLabel = bundleKind === 'zip' ? 'zip archive' : 'folder'; + meta.textContent = `${groupRecord.children.length} part${groupRecord.children.length === 1 ? '' : 's'} • ${kindLabel} • ${printedCount}/${groupRecord.children.length} printed • click to preview all`; + } else { + meta.textContent = `${groupRecord.children.length} model${groupRecord.children.length === 1 ? '' : 's'} • ${printedCount}/${groupRecord.children.length} printed`; + } details.appendChild(titleRow); details.appendChild(meta); @@ -19716,15 +19985,46 @@ function createParentModelGroupItem(groupRecord, viewMode = null) { } }; + chevron.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + toggleGroup(); + }); + item.addEventListener('click', (event) => { + if (event.target.closest('.parent-model-group-chevron')) return; event.preventDefault(); event.stopPropagation(); + if (groupRecord.groupKind === 'bundle' || groupRecord.groupKind === 'zip') { + if (typeof window.openBundlePreview === 'function') { + window.openBundlePreview(groupRecord); + } else { + showBundleDetails(groupRecord); + } + return; + } toggleGroup(); }); + item.addEventListener('dblclick', (event) => { + if (event.target.closest('.parent-model-group-chevron')) return; + if (groupRecord.groupKind === 'bundle' || groupRecord.groupKind === 'zip') { + event.preventDefault(); + event.stopPropagation(); + showBundleDetails(groupRecord); + } + }); item.addEventListener('keydown', (event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - toggleGroup(); + if (groupRecord.groupKind === 'bundle' || groupRecord.groupKind === 'zip') { + if (typeof window.openBundlePreview === 'function') { + window.openBundlePreview(groupRecord); + } else { + showBundleDetails(groupRecord); + } + } else { + toggleGroup(); + } } }); item.addEventListener('contextmenu', async (event) => { @@ -19754,6 +20054,7 @@ function renderVirtualGrid(models) { if (!container) return; models = dedupeModelsForVirtualGrid(models || []); + pruneBundleExpandedGroups(models); pruneParentModelExpandedGroups(models); pruneZipArchiveExpandedGroups(models); diff --git a/server-bridge.js b/server-bridge.js index 6bb0c8c..dac0b3a 100644 --- a/server-bridge.js +++ b/server-bridge.js @@ -380,6 +380,7 @@ 'getModelsWithDefaultThumbnails': 'get-models-with-default-thumbnails', 'fetchMakerWorldPage': 'fetch-makerworld-page', 'getSlicers': 'get-slicers', + 'openFileInSlicer': 'open-file-in-slicer', 'saveSlicer': 'save-slicer', 'deleteSlicer': 'delete-slicer', 'clearAndSaveSlicers': 'clear-and-save-slicers', diff --git a/slicer.js b/slicer.js index 2d46745..04c735e 100644 --- a/slicer.js +++ b/slicer.js @@ -314,4 +314,6 @@ document.addEventListener('DOMContentLoaded', () => { window.electron.onOpenSlicerSettings(() => { openSlicerSettings(); }); + + window.openSlicerSettings = openSlicerSettings; }); \ No newline at end of file diff --git a/styles.css b/styles.css index 36d9994..adb9457 100644 --- a/styles.css +++ b/styles.css @@ -468,8 +468,86 @@ button:disabled { max-width: none; /* Allow full width expansion */ background: color-mix(in srgb, var(--model-background-color) 25%, transparent); padding: 20px 0; - border-top: 1px solid #444; border-bottom: 1px solid #444; + border-top: 1px solid #444; +} + +.bundle-details-subtitle { + margin: 0 0 12px; + color: var(--text-muted, #aaa); + font-size: 0.92rem; +} + +.bundle-details-path-row { + display: flex; + gap: 8px; + align-items: center; + max-width: 100%; +} + +.bundle-details-path-row input { + flex: 1; + min-width: 0; +} + +.bundle-details-stats { + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + margin-bottom: 16px; + font-size: 0.9rem; + color: var(--text-muted, #bbb); +} + +.bundle-contents-table-wrap { + max-height: 320px; + overflow: auto; + border: 1px solid #444; + border-radius: 6px; +} + +.bundle-contents-table { + width: 100%; + border-collapse: collapse; + font-size: 0.88rem; +} + +.bundle-contents-table th, +.bundle-contents-table td { + padding: 8px 10px; + text-align: left; + border-bottom: 1px solid #3a3a3a; +} + +.bundle-contents-table th { + position: sticky; + top: 0; + background: #2a2a2a; + z-index: 1; +} + +.bundle-contents-table tbody tr { + cursor: pointer; +} + +.bundle-contents-table tbody tr:hover { + background: color-mix(in srgb, var(--accent-color, #4a9eff) 12%, transparent); +} + +.bundle-contents-table tbody tr.bundle-contents-row-active { + background: color-mix(in srgb, var(--accent-color, #4a9eff) 22%, transparent); +} + +.bundle-details-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.parent-model-group.bundle-details-active { + outline: 2px solid color-mix(in srgb, var(--accent-color, #4a9eff) 65%, transparent); + outline-offset: 2px; } /* ============================================ @@ -6640,6 +6718,46 @@ header { font-size: 16px; } +.preview-slicer-button:not(:disabled) { + border-color: rgba(74, 158, 255, 0.35); + background: rgba(74, 158, 255, 0.12); +} + +.preview-slicer-menu { + position: relative; + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 8px; + padding: 8px; + border-radius: 8px; + border: 1px solid rgba(74, 158, 255, 0.25); + background: rgba(20, 24, 36, 0.95); + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +.preview-slicer-menu.hidden { + display: none; +} + +.preview-slicer-menu-item { + padding: 10px 14px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(255, 255, 255, 0.04); + color: #fff; + font-size: 14px; + text-align: left; + cursor: pointer; +} + +.preview-slicer-menu-item:hover { + background: rgba(74, 158, 255, 0.2); + border-color: rgba(74, 158, 255, 0.35); +} + .preview-info { display: flex; gap: 20px;