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.
+
+ Close
+
+
+ `;
+ 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;