diff --git a/.gitignore b/.gitignore index aa84eee..b8cd622 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 2f9c5fa..12f9460 100644 --- a/index.html +++ b/index.html @@ -304,6 +304,40 @@

    Model Details

    + +

    Add New Designer

    @@ -943,6 +977,9 @@

    Model Preview

    + @@ -953,6 +990,7 @@

    Model Preview

    📐 Axes
    +
    diff --git a/main.js b/main.js index e28708d..a77597d 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'); // macOS: Chromium can refuse WebGL for blocklisted GPUs or strict context options. // Must be set before app ready so Three.js thumbnail rendering can create a context. @@ -1753,7 +1754,7 @@ function loadThumbnailForModel(filePath) { } } -const MODEL_DETAIL_COLUMNS = 'id, filePath, fileName, designer, source, notes, printed, parentModel, hash, size, license, modifiedDate, dateAdded, isNew, rating, favorite'; +const MODEL_DETAIL_COLUMNS = 'id, filePath, fileName, designer, source, notes, printed, parentModel, hash, size, license, modifiedDate, dateAdded, isNew, rating, favorite, bundleKey, bundleLabel, bundleKind'; function getModelByFilePath(filePath, { includeThumbnail = false } = {}) { if (!db || !filePath) return null; @@ -2012,7 +2013,10 @@ function initializeDatabase() { dateAdded DATETIME, isNew INTEGER DEFAULT 1, rating INTEGER DEFAULT 0, - favorite INTEGER DEFAULT 0 + favorite INTEGER DEFAULT 0, + bundleKey TEXT, + bundleLabel TEXT, + bundleKind TEXT )`).run(); // Create tags table @@ -2072,6 +2076,7 @@ function initializeDatabase() { migrateDateAddedColumn(); migrateIsNewColumn(); migrateRatingFavoriteColumns(); + 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(); @@ -2182,6 +2187,48 @@ function migrateRatingFavoriteColumns() { } } +/** 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; + } +} + function normalizeModelRating(value) { const n = parseInt(value, 10); if (Number.isNaN(n) || n < 0) return 0; @@ -3280,14 +3327,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 @@ -3313,12 +3366,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 { @@ -3329,7 +3386,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 @@ -3614,7 +3674,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, rating, favorite, 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, rating, favorite, 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) { @@ -4298,7 +4358,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, models.rating, models.favorite, 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.rating, models.favorite, 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 @@ -6120,7 +6180,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 @@ -6133,23 +6192,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); @@ -7421,6 +7465,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; @@ -8817,10 +8952,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 = getModelByFilePath(filePath); if (!model) { @@ -9396,7 +9540,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, rating, favorite, 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, rating, favorite, 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 ? @@ -9412,7 +9556,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, rating, favorite, 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, rating, favorite, 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); @@ -9600,6 +9744,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); @@ -9630,47 +9830,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' }; @@ -10175,6 +10368,7 @@ async function saveModel(modelData) { `).all(existingModel.id).map((row) => row.name); clearIsNew = JSON.stringify(sortedTagNames(existingTagRows)) !== JSON.stringify(sortedTagNames(tags)); } + const bundle = deriveBundleFromFilePath(filePath); // Use a simpler update approach to avoid foreign key issues const updateStmt = db.prepare(` @@ -10188,6 +10382,9 @@ async function saveModel(modelData) { license = ?, rating = ?, favorite = ?, + bundleKey = ?, + bundleLabel = ?, + bundleKind = ?, isNew = CASE WHEN ? THEN 0 ELSE isNew END WHERE id = ? `); @@ -10202,6 +10399,9 @@ async function saveModel(modelData) { finalLicense, finalRating, finalFavorite, + bundle.bundleKey || null, + bundle.bundleLabel || null, + bundle.bundleKind || null, clearIsNew ? 1 : 0, existingModel.id ); @@ -10212,10 +10412,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, rating, favorite - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?) + filePath, fileName, designer, source, notes, printed, parentModel, license, + dateAdded, isNew, rating, favorite, bundleKey, bundleLabel, bundleKind + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?) `); const result = insertStmt.run( @@ -10229,7 +10431,10 @@ async function saveModel(modelData) { license || null, dateAdded, normalizeModelRating(rating), - favorite ? 1 : 0 + favorite ? 1 : 0, + bundle.bundleKey || null, + bundle.bundleLabel || null, + bundle.bundleKind || null ); modelId = result.lastInsertRowid; diff --git a/package.json b/package.json index 313c393..0798829 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "discord:latest-builds:init": "node scripts/publish-beta-release.js --discord-init", "postinstall": "electron-builder install-app-deps", "test": "playwright test", + "test:bundle": "node bundle-keys.test.js", "test:full": "playwright test full-app-e2e.spec.js" }, "author": "TechJeeper Designs", @@ -108,6 +109,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 a5b23ac..c70bfa5 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; function formatUserFacingPreviewError(error) { let message = error?.message || String(error || 'Unknown error'); @@ -109,6 +110,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) { @@ -141,6 +156,8 @@ console.log('[Preview] preview.js script loaded'); preview3mfRequestId = null; } currentFilePath = filePath; + currentBundleGroupRecord = null; + hidePreviewSlicerMenu(); const loadToken = ++previewLoadToken; const dialog = document.getElementById('preview-dialog'); @@ -161,6 +178,7 @@ console.log('[Preview] preview.js script loaded'); resetPreviewLoadingUI(); fileType.textContent = ''; dimensions.textContent = ''; + updatePreviewSlicerButton(); // Open dialog dialog.showModal(); @@ -178,6 +196,7 @@ console.log('[Preview] preview.js script loaded'); if (loadToken !== previewLoadToken) return; console.log('Model loaded successfully'); loading.style.display = 'none'; + updatePreviewSlicerButton(); } catch (error) { if (loadToken !== previewLoadToken) return; const message = error && error.message ? error.message : ''; @@ -203,7 +222,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() { @@ -478,153 +498,412 @@ 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); - } + const meta = json.metadata || {}; + if (meta.previewSimplified && meta.sourceTriangles && meta.keptTriangles) { + const note = document.getElementById('preview-simplified-note'); + if (note) { + note.textContent = + `Simplified preview (${meta.keptTriangles.toLocaleString('en-US')} of ` + + `${meta.sourceTriangles.toLocaleString('en-US')} triangles)`; + note.style.display = 'inline'; + } + } else { + const note = document.getElementById('preview-simplified-note'); + if (note) note.style.display = 'none'; + } - previewModel = object; - previewScene.add(previewModel); + return object; + } - centerAndScaleModel(previewModel); - updateModelDimensions(previewModel); + // Load preview model + async function loadPreviewModel(filePath, loadToken) { + const ext = getPreviewExtension(filePath); + const fileType = document.getElementById('preview-file-type'); - const meta = json.metadata || {}; - if (meta.previewSimplified && meta.sourceTriangles && meta.keptTriangles) { - const note = document.getElementById('preview-simplified-note'); - if (note) { - note.textContent = - `Simplified preview (${meta.keptTriangles.toLocaleString('en-US')} of ` + - `${meta.sourceTriangles.toLocaleString('en-US')} triangles)`; - note.style.display = 'inline'; - } - } else { - const note = document.getElementById('preview-simplified-note'); - if (note) note.style.display = 'none'; - } + fileType.textContent = `Type: ${ext.toUpperCase()}`; + console.log('Loading model type:', ext); - preview3mfRequestId = null; - 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 @@ -845,6 +1124,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 d5dcfa3..f6307d2 100644 --- a/renderer.js +++ b/renderer.js @@ -1488,6 +1488,59 @@ function syncModelNewBadge(fileItem, model) { } } +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'); @@ -2488,6 +2541,8 @@ async function showModelDetails(filePath) { if (!model) return; + hideBundleDetailsPanel(); + // Get the details panel reference const detailsPanel = document.getElementById('model-details'); if (!detailsPanel) { @@ -10755,10 +10810,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` + @@ -10766,20 +10825,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 { @@ -10805,6 +10858,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`; @@ -19322,6 +19406,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 = {}; @@ -19500,15 +19585,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, @@ -19801,6 +19886,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(); @@ -19813,7 +20060,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}".`; @@ -19951,8 +20202,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') { @@ -19961,6 +20220,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); @@ -19983,7 +20245,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')); } @@ -20029,7 +20293,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); @@ -20059,15 +20328,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) => { @@ -20097,6 +20397,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 f6b070d..d7bbbea 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; } /* ============================================ @@ -6770,6 +6848,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;