diff --git a/__init__.py b/__init__.py index b498e1d4..d6e0df3d 100644 --- a/__init__.py +++ b/__init__.py @@ -52,8 +52,47 @@ def _register_gpack_routes(): items.sort() return web.json_response(items) + # /gpack/save_preview -- the viewers' "Save mesh" button. Copies a temp + # preview export (preview_*.stl/vtp/glb, written to ComfyUI output/ or the + # OS tempdir by the preview nodes) to a stable name in output/ so it + # survives the next preview run. Body: {"temp_filename": ""}. + @inst.routes.post("/gpack/save_preview") + async def _gpack_save_preview(request): + import shutil + try: + body = await request.json() + except Exception: + return web.json_response({"success": False, "error": "invalid JSON body"}, status=400) + # basename() defuses path traversal -- we only ever serve files the + # preview nodes themselves wrote into these two directories. + name = os.path.basename(str(body.get("temp_filename") or "")) + if not name: + return web.json_response({"success": False, "error": "temp_filename required"}, status=400) + import tempfile + try: + import folder_paths + out_dir = folder_paths.get_output_directory() + except Exception: + out_dir = None + candidates = [d for d in (out_dir, tempfile.gettempdir()) if d] + src = next((os.path.join(d, name) for d in candidates + if os.path.isfile(os.path.join(d, name))), None) + if src is None: + return web.json_response( + {"success": False, "error": f"{name} not found (previews are temporary; re-run the node)"}, + status=404) + if out_dir is None: + return web.json_response({"success": False, "error": "no ComfyUI output directory"}, status=500) + saved = f"saved_{name}" + try: + shutil.copy2(src, os.path.join(out_dir, saved)) + except OSError as e: + return web.json_response({"success": False, "error": str(e)}, status=500) + log.info("[GeomPack] saved preview %s -> %s", name, saved) + return web.json_response({"success": True, "saved_filename": saved}) + inst._gpack_getpath_registered = True - log.info("[GeomPack] /gpack/getpath route registered") + log.info("[GeomPack] /gpack/getpath + /gpack/save_preview routes registered") try: diff --git a/javascript/js/mesh_preview_multi.js b/javascript/js/mesh_preview_multi.js index a3a3eb96..2ae24a46 100644 --- a/javascript/js/mesh_preview_multi.js +++ b/javascript/js/mesh_preview_multi.js @@ -181,13 +181,27 @@ app.registerExtension({ iframe.src = `/extensions/${EXTENSION_FOLDER}/${viewerUrl()}?v=` + Date.now(); }); - const onDim = () => { - viewerState.grid_cols = colsInput.value ? Math.max(1, parseInt(colsInput.value, 10)) : null; - viewerState.grid_rows = rowsInput.value ? Math.max(1, parseInt(rowsInput.value, 10)) : null; + // The edited field is authoritative; the OTHER dimension auto-raises + // so cols*rows always fits every mesh. Without this, a grid smaller + // than the mesh count overflows into implicit CSS rows (auto height) + // and the layout stops matching the inputs. Both inputs are updated + // so the UI always shows the grid actually in effect. + const onDim = (edited) => () => { + const n = lastLoad?.numMeshes || 1; + let cols = Math.max(1, parseInt(colsInput.value, 10) || 0) || (lastLoad?.autoCols ?? 1); + let rows = Math.max(1, parseInt(rowsInput.value, 10) || 0) || (lastLoad?.autoRows ?? 1); + if (cols * rows < n) { + if (edited === "cols") rows = Math.ceil(n / cols); + else cols = Math.ceil(n / rows); + } + colsInput.value = cols; + rowsInput.value = rows; + viewerState.grid_cols = cols; + viewerState.grid_rows = rows; buildAndSend(); // same iframe -> viewer re-tiles, no reload }; - colsInput.addEventListener('change', onDim); - rowsInput.addEventListener('change', onDim); + colsInput.addEventListener('change', onDim("cols")); + rowsInput.addEventListener('change', onDim("rows")); syncLayoutUI(); applyCollapsed(); diff --git a/javascript/viewer_vtk.html b/javascript/viewer_vtk.html index 99f124e6..07826049 100644 --- a/javascript/viewer_vtk.html +++ b/javascript/viewer_vtk.html @@ -1031,7 +1031,7 @@ saveButton.disabled = true; saveButton.textContent = 'Saving...'; - const response = await fetch('/geometrypack/save_preview', { + const response = await fetch('/gpack/save_preview', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ temp_filename: currentFilename })