From 6d3f28d6c4ad6170f83036202dc419e2b66a022c Mon Sep 17 00:00:00 2001 From: Lightning Pixel Date: Wed, 11 Nov 2026 11:07:11 +0100 Subject: [PATCH] feat(generate): multi-level mesh undo with a bounded per-model stack Mesh edits never overwrite their input (every op writes a new workspace file), so undo only needs the chain of URLs. Move it into a pure meshHistory module and route every edit through one store action: - applyMeshEdit(url) records the edit from the mesh currently shown, seeding it as the baseline when it was never recorded (workflow outputs, Add to Scene), so the first edit is always undoable. - The stack is capped at 20 undo steps; a new edit drops the redo branch. - setCurrentJob resets the stack, so undo never jumps to another model (import, library open, Add to Scene and workflow runs used to keep it). - Smooth, decimate and agent mesh tools all go through applyMeshEdit. - The undo button shows the number of available steps. Closes #304 --- package.json | 2 +- src/areas/generate/GeneratePage.tsx | 30 ++-- src/areas/generate/components/ChatPanel.tsx | 4 +- src/shared/stores/appStore.ts | 60 +++++--- src/shared/stores/meshHistory.test.ts | 154 ++++++++++++++++++++ src/shared/stores/meshHistory.ts | 61 ++++++++ 6 files changed, 274 insertions(+), 37 deletions(-) create mode 100644 src/shared/stores/meshHistory.test.ts create mode 100644 src/shared/stores/meshHistory.ts diff --git a/package.json b/package.json index a636bdd51..da6236ad2 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "prepare-resources": "node scripts/download-python-embed.js", "test": "npm run test:py && npm run test:node", "test:py": "node scripts/run-pytests.mjs", - "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", + "test:node": "node --test --experimental-strip-types --experimental-loader ./scripts/node-ts-extensionless-loader.mjs src/shared/types/assetLibrary.test.ts src/areas/generate/assetLibraryProjection.test.ts src/areas/generate/assetLibraryService.test.ts src/areas/generate/assetLibraryUi.test.ts src/areas/generate/orcaSlicerLink.test.ts src/shared/utils/agentStream.test.ts src/shared/utils/ollamaHealth.test.ts src/shared/utils/notification.test.ts electron/main/artifact-registry-service.test.ts electron/main/extension-path-guard.test.ts electron/main/storage-directory-guard.test.ts electron/main/storage-ownership.test.ts electron/main/startup-error.test.ts electron/main/api-port.test.ts electron/preload/artifact-registry-preload.test.ts src/shared/stores/meshHistory.test.ts && node --test electron/main/*.test.mjs src/**/*.test.mjs", "package": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder", "package:mac": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false npm run build && npm run prepare-resources && electron-builder --mac --arm64", "lint": "eslint ." diff --git a/src/areas/generate/GeneratePage.tsx b/src/areas/generate/GeneratePage.tsx index 76a131199..846392396 100644 --- a/src/areas/generate/GeneratePage.tsx +++ b/src/areas/generate/GeneratePage.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useCallback, useEffect, useMemo } from 'react' import type { ReactNode } from 'react' import { useAppStore, DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore' import type { GenerationJob, LightSettings, PointLight } from '@shared/stores/appStore' +import { redoDepth, undoDepth } from '@shared/stores/meshHistory' import { useApi } from '@shared/hooks/useApi' import { ColorPicker } from '@shared/components/ui' import GenerationHUD from './components/GenerationHUD' @@ -691,15 +692,15 @@ export default function GeneratePage(): JSX.Element { const currentJob = useAppStore((s) => s.currentJob) const apiUrl = useAppStore((s) => s.apiUrl) const showError = useAppStore((s) => s.showError) - const updateCurrentJob = useAppStore((s) => s.updateCurrentJob) const setCurrentJob = useAppStore((s) => s.setCurrentJob) const meshStats = useAppStore((s) => s.meshStats) const meshSelected = useAppStore((s) => s.meshSelected) const pushMeshUrl = useAppStore((s) => s.pushMeshUrl) const undoMesh = useAppStore((s) => s.undoMesh) const redoMesh = useAppStore((s) => s.redoMesh) - const canUndo = useAppStore((s) => s.historyIndex > 0) - const canRedo = useAppStore((s) => s.historyIndex < s.meshHistory.length - 1) + const applyMeshEdit = useAppStore((s) => s.applyMeshEdit) + const undoSteps = useAppStore((s) => undoDepth(s.meshHistory)) + const redoSteps = useAppStore((s) => redoDepth(s.meshHistory)) const { optimizeMesh, smoothMesh, importMesh } = useApi() const assetLibraryService = useMemo(() => getDefaultAssetLibraryService(), []) @@ -894,8 +895,7 @@ export default function GeneratePage(): JSX.Element { try { const path = getOptimizePath(currentJob.outputUrl) const { url } = await smoothMesh(path, iterations) - updateCurrentJob({ outputUrl: url }) - pushMeshUrl(url) + applyMeshEdit(url) setOpenPanel(null) } catch (err) { showError(err instanceof Error ? err.message : String(err)) @@ -910,8 +910,7 @@ export default function GeneratePage(): JSX.Element { try { const path = getOptimizePath(currentJob.outputUrl) const { url } = await optimizeMesh(path, targetFaces) - updateCurrentJob({ outputUrl: url }) - pushMeshUrl(url) + applyMeshEdit(url) setOpenPanel(null) } catch (err) { showError(err instanceof Error ? err.message : String(err)) @@ -971,20 +970,25 @@ export default function GeneratePage(): JSX.Element { {/* Undo / Redo */}