Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 ."
Expand Down
30 changes: 17 additions & 13 deletions src/areas/generate/GeneratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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(), [])

Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down Expand Up @@ -971,20 +970,25 @@ export default function GeneratePage(): JSX.Element {
{/* Undo / Redo */}
<button
onClick={undoMesh}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
className="flex items-center justify-center w-7 h-7 rounded-lg text-[11px] font-medium bg-zinc-800 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 transition-colors disabled:opacity-30 disabled:pointer-events-none"
disabled={undoSteps === 0}
title={undoSteps > 0 ? `Undo (Ctrl+Z) — ${undoSteps} step${undoSteps > 1 ? 's' : ''}` : 'Undo (Ctrl+Z)'}
className="relative flex items-center justify-center w-7 h-7 rounded-lg text-[11px] font-medium bg-zinc-800 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 transition-colors disabled:opacity-30 disabled:pointer-events-none"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M3 7v6h6" />
<path d="M3 13a9 9 0 1 0 2.28-5.93" />
</svg>
{undoSteps > 0 && (
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-0.5 rounded-full bg-zinc-700 text-zinc-200 text-[9px] leading-[14px] text-center">
{undoSteps}
</span>
)}
</button>
<button
onClick={redoMesh}
disabled={!canRedo}
title="Redo (Ctrl+Y)"
className="flex items-center justify-center w-7 h-7 rounded-lg text-[11px] font-medium bg-zinc-800 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 transition-colors disabled:opacity-30 disabled:pointer-events-none"
disabled={redoSteps === 0}
title={redoSteps > 0 ? `Redo (Ctrl+Y) — ${redoSteps} step${redoSteps > 1 ? 's' : ''}` : 'Redo (Ctrl+Y)'}
className="relative flex items-center justify-center w-7 h-7 rounded-lg text-[11px] font-medium bg-zinc-800 border border-zinc-700/50 text-zinc-400 hover:text-zinc-200 hover:border-zinc-600 transition-colors disabled:opacity-30 disabled:pointer-events-none"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M21 7v6h-6" />
Expand Down
4 changes: 2 additions & 2 deletions src/areas/generate/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,7 @@ export default function ChatPanel(): JSX.Element {
const meshStats = useAppStore((s) => s.meshStats)
const updateCurrentJob = useAppStore((s) => s.updateCurrentJob)
const pushMeshUrl = useAppStore((s) => s.pushMeshUrl)
const applyMeshEdit = useAppStore((s) => s.applyMeshEdit)
const undoMesh = useAppStore((s) => s.undoMesh)

const isOllama = provider === 'ollama'
Expand Down Expand Up @@ -494,8 +495,7 @@ export default function ChatPanel(): JSX.Element {

async function applyToolPayload(payload: AgentActionPayload | null) {
if (payload?.type === 'mesh_update' && payload.url) {
updateCurrentJob({ outputUrl: payload.url })
pushMeshUrl(payload.url)
applyMeshEdit(payload.url)
}
if (payload?.type === 'create_workflow' && payload.workflow) {
const draft = payload.workflow as Omit<Workflow, 'id' | 'createdAt' | 'updatedAt'>
Expand Down
60 changes: 39 additions & 21 deletions src/shared/stores/appStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import {
createMeshHistory,
currentMeshUrl,
recordMesh,
recordMeshEdit,
stepBack,
stepForward,
type MeshHistory,
} from './meshHistory'

export type UiScale = 'small' | 'medium' | 'large' | 'very-large'
export type BackendStatus = 'not_started' | 'starting' | 'ready' | 'error'
Expand Down Expand Up @@ -133,10 +142,12 @@ interface AppState {
showToast: (message: string, durationMs?: number) => void
hideToast: () => void

// Mesh URL history (undo/redo)
meshHistory: string[]
historyIndex: number
// Mesh undo/redo stack for the model in the viewer (reset by setCurrentJob)
meshHistory: MeshHistory
/** Record a freshly loaded/generated mesh as the current state. */
pushMeshUrl: (url: string) => void
/** Show an edited mesh, undoable back to the one it was made from. */
applyMeshEdit: (url: string) => void
undoMesh: () => void
redoMesh: () => void
clearMeshHistory: () => void
Expand Down Expand Up @@ -221,32 +232,34 @@ export const useAppStore = create<AppState>()(
showToast: (message, durationMs) => set({ toast: { id: Date.now(), message, durationMs } }),
hideToast: () => set({ toast: null }),

meshHistory: [],
historyIndex: -1,
meshHistory: createMeshHistory(),

pushMeshUrl: (url) => {
const { meshHistory, historyIndex } = get()
const next = [...meshHistory.slice(0, historyIndex + 1), url]
set({ meshHistory: next, historyIndex: next.length - 1 })
pushMeshUrl: (url) => set({ meshHistory: recordMesh(get().meshHistory, url) }),

applyMeshEdit: (url) => {
const { currentJob, meshHistory } = get()
if (!currentJob) return
set({
meshHistory: recordMeshEdit(meshHistory, currentJob.outputUrl, url),
currentJob: { ...currentJob, outputUrl: url },
})
},

undoMesh: () => {
const { meshHistory, historyIndex } = get()
if (historyIndex <= 0) return
const newIndex = historyIndex - 1
set({ historyIndex: newIndex })
get().updateCurrentJob({ outputUrl: meshHistory[newIndex] })
const meshHistory = stepBack(get().meshHistory)
if (meshHistory === get().meshHistory) return
set({ meshHistory })
get().updateCurrentJob({ outputUrl: currentMeshUrl(meshHistory) })
},

redoMesh: () => {
const { meshHistory, historyIndex } = get()
if (historyIndex >= meshHistory.length - 1) return
const newIndex = historyIndex + 1
set({ historyIndex: newIndex })
get().updateCurrentJob({ outputUrl: meshHistory[newIndex] })
const meshHistory = stepForward(get().meshHistory)
if (meshHistory === get().meshHistory) return
set({ meshHistory })
get().updateCurrentJob({ outputUrl: currentMeshUrl(meshHistory) })
},

clearMeshHistory: () => set({ meshHistory: [], historyIndex: -1 }),
clearMeshHistory: () => set({ meshHistory: createMeshHistory() }),

showRamIndicator: true,
setShowRamIndicator: (v) => set({ showRamIndicator: v }),
Expand Down Expand Up @@ -295,7 +308,12 @@ export const useAppStore = create<AppState>()(
}
},

setCurrentJob: (job) => set({ currentJob: job, meshStats: job === null ? null : get().meshStats }),
// A new job is a different model: its undo stack starts from its own mesh.
setCurrentJob: (job) => set({
currentJob: job,
meshStats: job === null ? null : get().meshStats,
meshHistory: createMeshHistory(job?.status === 'done' ? job.outputUrl : undefined),
}),

updateCurrentJob: (patch) => {
const current = get().currentJob
Expand Down
154 changes: 154 additions & 0 deletions src/shared/stores/meshHistory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict'
import test from 'node:test'

import {
MESH_HISTORY_LIMIT,
createMeshHistory,
currentMeshUrl,
recordMesh,
recordMeshEdit,
redoDepth,
stepBack,
stepForward,
undoDepth,
} from './meshHistory.ts'

test('3 edits then 3 undos return the mesh to its initial state', () => {
let h = createMeshHistory('/workspace/a.glb')
h = recordMeshEdit(h, '/workspace/a.glb', '/workspace/b.glb')
h = recordMeshEdit(h, '/workspace/b.glb', '/workspace/c.glb')
h = recordMeshEdit(h, '/workspace/c.glb', '/workspace/d.glb')
assert.equal(undoDepth(h), 3)

h = stepBack(h)
assert.equal(currentMeshUrl(h), '/workspace/c.glb')
h = stepBack(h)
assert.equal(currentMeshUrl(h), '/workspace/b.glb')
h = stepBack(h)
assert.equal(currentMeshUrl(h), '/workspace/a.glb')
assert.equal(undoDepth(h), 0)
assert.equal(redoDepth(h), 3)
})

test('an edit on a mesh that was never recorded seeds it as the undo baseline', () => {
// e.g. a workflow output or an Add-to-Scene mesh that bypassed recordMesh
let h = createMeshHistory()
h = recordMeshEdit(h, '/workspace/wf.glb', '/workspace/wf_smooth.glb')
assert.equal(undoDepth(h), 1)
assert.equal(currentMeshUrl(stepBack(h)), '/workspace/wf.glb')
})

test('an empty history cannot step in either direction', () => {
const h = createMeshHistory()
assert.equal(undoDepth(h), 0)
assert.equal(redoDepth(h), 0)
assert.equal(stepBack(h), h)
assert.equal(stepForward(h), h)
assert.equal(currentMeshUrl(h), undefined)
})

test('redo replays undone edits, and a new edit drops the redo branch', () => {
let h = createMeshHistory('a')
h = recordMeshEdit(h, 'a', 'b')
h = recordMeshEdit(h, 'b', 'c')
h = stepBack(stepBack(h))
h = stepForward(h)
assert.equal(currentMeshUrl(h), 'b')

h = recordMeshEdit(h, 'b', 'x')
assert.equal(redoDepth(h), 0)
assert.equal(stepForward(h), h)
assert.deepEqual(h.entries, ['a', 'b', 'x'])
})

test('recording the mesh that is already current is a no-op', () => {
const h = createMeshHistory('a')
assert.equal(recordMesh(h, 'a'), h)
const seeded = recordMesh(createMeshHistory(), 'a')
assert.deepEqual(seeded.entries, ['a'])
assert.equal(undoDepth(seeded), 0)
})

test('the stack keeps at most MESH_HISTORY_LIMIT undo steps, dropping the oldest', () => {
let h = createMeshHistory('m0')
for (let i = 1; i <= MESH_HISTORY_LIMIT + 5; i++) h = recordMeshEdit(h, `m${i - 1}`, `m${i}`)
assert.equal(undoDepth(h), MESH_HISTORY_LIMIT)
assert.equal(currentMeshUrl(h), `m${MESH_HISTORY_LIMIT + 5}`)
while (undoDepth(h) > 0) h = stepBack(h)
assert.equal(currentMeshUrl(h), 'm5')
})

test('the limit is applicable per call', () => {
let h = createMeshHistory('a')
h = recordMeshEdit(h, 'a', 'b', 1)
h = recordMeshEdit(h, 'b', 'c', 1)
assert.deepEqual(h.entries, ['b', 'c'])
assert.equal(undoDepth(h), 1)
})

// ── Store wiring ─────────────────────────────────────────────────────────────

const { useAppStore } = await import('./appStore.ts')

function loadModel(id: string, url: string): void {
useAppStore.getState().setCurrentJob({
id, imageFile: '', status: 'done', progress: 100, outputUrl: url, createdAt: 0,
})
}

const viewerUrl = () => useAppStore.getState().currentJob?.outputUrl

test('store: 3 edits then 3 undos restore the initial mesh, redo replays them', () => {
loadModel('job-1', '/workspace/a.glb')
const s = useAppStore.getState()
s.applyMeshEdit('/workspace/b.glb')
s.applyMeshEdit('/workspace/c.glb')
s.applyMeshEdit('/workspace/d.glb')
assert.equal(viewerUrl(), '/workspace/d.glb')

s.undoMesh(); s.undoMesh(); s.undoMesh()
assert.equal(viewerUrl(), '/workspace/a.glb')
s.undoMesh()
assert.equal(viewerUrl(), '/workspace/a.glb')

s.redoMesh(); s.redoMesh(); s.redoMesh()
assert.equal(viewerUrl(), '/workspace/d.glb')
})

test('store: loading another model clears the stack so undo never crosses models', () => {
loadModel('job-1', '/workspace/a.glb')
useAppStore.getState().applyMeshEdit('/workspace/b.glb')

loadModel('job-2', '/workspace/other.glb')
assert.equal(undoDepth(useAppStore.getState().meshHistory), 0)
useAppStore.getState().undoMesh()
assert.equal(viewerUrl(), '/workspace/other.glb')
})

test('store: reloading under the same job id still starts a fresh stack', () => {
// Add-to-Scene reuses the id "workflow-output" for every mesh it opens.
loadModel('workflow-output', '/workspace/one.glb')
useAppStore.getState().applyMeshEdit('/workspace/one_edit.glb')
loadModel('workflow-output', '/workspace/two.glb')
useAppStore.getState().undoMesh()
assert.equal(viewerUrl(), '/workspace/two.glb')
})

test('store: a mesh set without recording is still the baseline of the next edit', () => {
useAppStore.getState().setCurrentJob({
id: 'wf', imageFile: '', status: 'generating', progress: 0, createdAt: 0,
})
// A workflow run finishing sets the output without pushing it.
useAppStore.getState().updateCurrentJob({ status: 'done', outputUrl: '/workspace/wf.glb' })
useAppStore.getState().applyMeshEdit('/workspace/wf_smooth.glb')
useAppStore.getState().undoMesh()
assert.equal(viewerUrl(), '/workspace/wf.glb')
})

test('store: clearing the scene empties the stack', () => {
loadModel('job-1', '/workspace/a.glb')
useAppStore.getState().applyMeshEdit('/workspace/b.glb')
useAppStore.getState().setCurrentJob(null)
assert.equal(undoDepth(useAppStore.getState().meshHistory), 0)
assert.equal(redoDepth(useAppStore.getState().meshHistory), 0)
})
Loading
Loading