diff --git a/e2e/drivers/svelte.ts b/e2e/drivers/svelte.ts index d1b1935..a4d8f9a 100644 --- a/e2e/drivers/svelte.ts +++ b/e2e/drivers/svelte.ts @@ -825,6 +825,23 @@ export class SvelteDriver implements UiDriver { await this.clickAndWaitForLogChange(this.page.getByTestId('upscale-textures')); } + async view3dScene(): Promise<{ url: string; materials: number }> { + await this.ensureView('3d'); + const viewer = this.page.getByTestId('model-viewer'); + await expect(viewer).toBeVisible({ timeout: 30_000 }); + const handle = await this.page.waitForFunction( + () => { + type ModelViewer = HTMLElement & { loaded?: boolean; src?: string; model?: { materials: unknown[] } }; + const element = document.querySelector('[data-testid="model-viewer"]') as ModelViewer | null; + if (!element?.loaded || !element.model || !element.src) return null; + return { url: element.src, materials: element.model.materials.length }; + }, + undefined, + { timeout: 30_000 }, + ); + return (await handle.jsonValue()) as { url: string; materials: number }; + } + async downloadSlice(index: number): Promise { await this.closeDialogs(); await this.ensureSelected(index); diff --git a/e2e/drivers/types.ts b/e2e/drivers/types.ts index c9e7d74..4b06689 100644 --- a/e2e/drivers/types.ts +++ b/e2e/drivers/types.ts @@ -226,4 +226,9 @@ export interface UiDriver { upscaleTextures(): Promise; /** Clicks a slice thumbnail's download icon and returns the resulting browser download. */ downloadSlice(index: number): Promise; + /** + * Opens the 3D view and waits until it shows a loaded scene: the glTF URL + * it loaded and the number of materials in it (one per slice). + */ + view3dScene(): Promise<{ url: string; materials: number }>; } diff --git a/e2e/project-export.spec.ts b/e2e/project-export.spec.ts index 892938c..34311f4 100644 --- a/e2e/project-export.spec.ts +++ b/e2e/project-export.spec.ts @@ -179,6 +179,22 @@ test('external server connection test highlights success when the fake probe suc await ui.expectExternalConnectionStatus('success'); }); +// --- 3D view: a stale glTF export is rebuilt before it is shown ------------------ + +test('the 3D view rebuilds the glTF scene after the slices change', async ({ ui }) => { + requireWorkflow(ui, 'export'); + await ui.restoreFixtureState(); + + // No export yet: entering the 3D view builds one with every slice. + const first = await ui.view3dScene(); + expect(first.materials).toBe(3); + + // Editing a slice makes that export stale; the view must not keep showing it. + await ui.setSliceDepth(1, 120); + await expect.poll(async () => (await ui.view3dScene()).url).not.toBe(first.url); + expect((await ui.view3dScene()).materials).toBe(3); +}); + // --- Export: glTF with displacement > 0 ----------------------------------------- test('glTF export with displacement produces a subdivided, non-flat mesh', async ({ ui }) => { diff --git a/frontend/src/lib/api/generated.ts b/frontend/src/lib/api/generated.ts index 2da6a6e..40fef2c 100644 --- a/frontend/src/lib/api/generated.ts +++ b/frontend/src/lib/api/generated.ts @@ -64,6 +64,7 @@ export type Kind1 = string; export type Progress = number; export type Clipboard = boolean; export type Depthmodel = string; +export type Gltfstale = boolean; export type Upscaled = boolean; export type Id1 = string; export type Numslices = number; @@ -417,6 +418,7 @@ export interface ProjectAssets { */ export interface ProjectExportsView { gltf?: AssetRef | null; + gltfStale?: Gltfstale; upscaled?: Upscaled; } /** diff --git a/frontend/src/lib/api/schema.json b/frontend/src/lib/api/schema.json index 7f3ac7e..40e8e7f 100644 --- a/frontend/src/lib/api/schema.json +++ b/frontend/src/lib/api/schema.json @@ -866,6 +866,11 @@ ], "default": null }, + "gltfStale": { + "default": false, + "title": "Gltfstale", + "type": "boolean" + }, "upscaled": { "default": false, "title": "Upscaled", diff --git a/frontend/src/lib/components/dialogs/ExportDialog.svelte b/frontend/src/lib/components/dialogs/ExportDialog.svelte index a9a9936..3e924bc 100644 --- a/frontend/src/lib/components/dialogs/ExportDialog.svelte +++ b/frontend/src/lib/components/dialogs/ExportDialog.svelte @@ -23,14 +23,14 @@ const view = $derived(projectStore.view); const ground = $derived(view?.slices.find((s) => s.isGround) ?? null); - const gltfReady = $derived(!!view?.exports.gltf); + const gltfReady = $derived(!!view?.exports.gltf && !view.exports.gltfStale); $effect(() => cameraDraftStore.sync(view?.settings)); - // -- Depth of Field checkbox: purely a parameter of the next export - // request, not a persisted setting (components.py's CHECKLIST_DOF has no - // "remember" callback of its own). - let dofEnabled = $state(false); + // -- Depth of Field checkbox: a parameter of export requests, not a + // persisted setting; kept in uiStore so the 3D view's automatic rebuild + // uses it too. + const dofEnabled = $derived(uiStore.exportDof); // -- Create glTF Scene (WEB-32): renders into the 3D viewer tab (see // Model3DViewer.svelte), same as Dash's own @@ -47,7 +47,7 @@ // `` click (not `window.open`), per the migration task. async function exportGltfScene(): Promise { if (!view || isBusy()) return; - await workflow.startGltfExport(dofEnabled); + if (!(await workflow.startGltfExport(dofEnabled))) return; const url = projectStore.view?.exports.gltf?.url; if (url) triggerDownload(url, 'scene.gltf'); } @@ -115,7 +115,7 @@ type="checkbox" data-testid="toggle-dof" checked={dofEnabled} - onchange={(event) => (dofEnabled = (event.currentTarget as HTMLInputElement).checked)} + onchange={(event) => uiStore.setExportDof((event.currentTarget as HTMLInputElement).checked)} /> Support depth-of-field effect diff --git a/frontend/src/lib/components/dialogs/ExportDialog.test.ts b/frontend/src/lib/components/dialogs/ExportDialog.test.ts index 4557a97..3759d37 100644 --- a/frontend/src/lib/components/dialogs/ExportDialog.test.ts +++ b/frontend/src/lib/components/dialogs/ExportDialog.test.ts @@ -199,6 +199,45 @@ describe('ExportTab', () => { }); }); + describe('Scene ready', () => { + it('is hidden without an export', () => { + projectStore.applyView(makeView()); + render(ExportTab); + expect(screen.queryByText('Scene ready')).not.toBeInTheDocument(); + expect(screen.queryByTestId('export-view-3d')).not.toBeInTheDocument(); + }); + + it('is hidden when the export exists but is stale', () => { + projectStore.applyView( + makeView({ + exports: { + gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, + gltfStale: true, + upscaled: false, + }, + }), + ); + render(ExportTab); + expect(screen.queryByText('Scene ready')).not.toBeInTheDocument(); + expect(screen.queryByTestId('export-view-3d')).not.toBeInTheDocument(); + }); + + it('is shown when the export exists and matches the current scene', () => { + projectStore.applyView( + makeView({ + exports: { + gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, + gltfStale: false, + upscaled: false, + }, + }), + ); + render(ExportTab); + expect(screen.getByText('Scene ready')).toBeInTheDocument(); + expect(screen.getByTestId('export-view-3d')).toBeInTheDocument(); + }); + }); + describe('View in 3D', () => { it('is absent until the scene has been exported', () => { projectStore.applyView(makeView()); @@ -262,13 +301,15 @@ describe('ExportTab', () => { }); }); - it('toggles the DOF checkbox locally without persisting anything', async () => { + it('the DOF checkbox writes uiStore.exportDof (shared with the 3D view auto-rebuild)', async () => { projectStore.applyView(makeView()); render(ExportTab); const checkbox = screen.getByTestId('toggle-dof') as HTMLInputElement; expect(checkbox.checked).toBe(false); + expect(uiStore.exportDof).toBe(false); await fireEvent.click(checkbox); expect(checkbox.checked).toBe(true); + expect(uiStore.exportDof).toBe(true); }); it('Export glTF Scene starts the export job and then triggers a real click, not window.open', async () => { diff --git a/frontend/src/lib/components/viewer/Model3DViewer.svelte b/frontend/src/lib/components/viewer/Model3DViewer.svelte index c67412b..a1587d2 100644 --- a/frontend/src/lib/components/viewer/Model3DViewer.svelte +++ b/frontend/src/lib/components/viewer/Model3DViewer.svelte @@ -1,19 +1,23 @@
@@ -41,6 +98,19 @@

Failed to load the 3D viewer.

{:else if !ready}

Loading 3D viewer…

+ {:else if needsBuild && failed && !building && !pending} +
+

Couldn't build the 3D scene.

+ +
+ {:else if needsBuild} +

+ {building || !isBusy() + ? 'Building the 3D scene…' + : 'The 3D scene will update when the current job finishes.'} +

{:else if gltfUrl} { return {}; }); +vi.mock('../../workflow', () => ({ startGltfExport: vi.fn() })); + import Model3DViewer from './Model3DViewer.svelte'; +import * as workflow from '../../workflow'; import { projectStore } from '../../state/project.svelte'; import { uiStore } from '../../state/ui.svelte'; -import type { ProjectView } from '../../api/types'; +import { jobStore } from '../../state/jobs.svelte'; +import type { ProjectView, SliceView, SceneProfileView } from '../../api/types'; + +function makeSlice(index: number, overrides: Partial = {}): SliceView { + return { + index, + depth: index * 50, + version: 1, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + isGround: false, + image: { url: `/api/v1/projects/appstate-test/assets/slice-${index}` }, + thumbnail: { url: `/api/v1/projects/appstate-test/assets/slice-${index}-thumb` }, + ...overrides, + } as SliceView; +} function makeView(overrides: Partial = {}): ProjectView { return { @@ -45,15 +65,67 @@ function makeView(overrides: Partial = {}): ProjectView { meshDisplacement: 0, depthModel: 'dinov2', }, - exports: { gltf: null, upscaled: false }, + exports: { gltf: null, gltfStale: false, upscaled: false }, ...overrides, }; } +/** Mirrors Model3DViewer.svelte's own `framing` derivation exactly, so the + * expected `` attribute strings are computed with the same + * floating-point math instead of brittle hard-coded decimals. */ +function expectedFraming(profile: SceneProfileView, maxDistance = 0) { + const depths = profile.cards.map((card) => card.z); + const middle = depths.length ? (Math.min(...depths) + Math.max(...depths)) / 2 : maxDistance / 2; + const pitch = (profile.pitch * Math.PI) / 180; + const along = Math.max(middle - profile.cameraZ, 1); + const radius = along / Math.cos(pitch); + return { + target: `0m ${along * Math.tan(pitch)}m ${middle}m`, + orbit: `180deg ${90 + profile.pitch}deg ${radius}m`, + maxOrbit: `auto auto ${radius * 4}m`, + fov: `${2 * profile.halfFov}deg`, + }; +} + +/** Flushes the microtask queue so chained `await`s inside `startGltfExport`'s + * mocked promise (and the component's own `.then`) get a chance to run. */ +function flush(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * Mocks `workflow.startGltfExport` the way the *real* implementation + * behaves with respect to `jobStore`: it marks the job busy (`jobStore.begin`) + * for as long as the call is in flight, and only clears it (`jobStore.end`) + * once the caller resolves it. This matters for these tests: the component's + * own auto-rebuild `$effect` re-reads `isBusy()`, and a naive mock that + * resolves instantly (without ever touching `jobStore`) lets that effect + * race and re-fire before the real implementation ever would (it stays busy + * across its own network/poll round trip). + */ +function mockStartGltfExport(): { resolve: (ok: boolean) => void } { + let resolveFn!: (ok: boolean) => void; + vi.mocked(workflow.startGltfExport).mockImplementation(() => { + jobStore.begin('export-gltf'); + return new Promise((resolve) => { + resolveFn = (ok: boolean) => { + jobStore.end(); + resolve(ok); + }; + }); + }); + return { + resolve: (ok: boolean) => resolveFn(ok), + }; +} + describe('Model3DViewer', () => { beforeEach(() => { projectStore.reset(); uiStore.reset(); + jobStore.end(); + jobStore.clearError(); + vi.mocked(workflow.startGltfExport).mockReset(); }); afterEach(() => { @@ -82,10 +154,12 @@ describe('Model3DViewer', () => { ); }); - it('renders pointing at the exported glTF asset once a scene exists', async () => { + it('renders pointing at the exported glTF asset once a current scene exists', async () => { uiStore.setView('3d'); projectStore.applyView( - makeView({ exports: { gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, upscaled: false } }), + makeView({ + exports: { gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, gltfStale: false, upscaled: false }, + }), ); render(Model3DViewer); await waitFor(() => @@ -95,4 +169,178 @@ describe('Model3DViewer', () => { ), ); }); + + describe('automatic rebuild of a stale/missing export', () => { + function staleView(overrides: Partial = {}): ProjectView { + return makeView({ + slices: [makeSlice(0)], + exports: { gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, gltfStale: true, upscaled: false }, + ...overrides, + }); + } + + it('fires with { preview: true } and the current uiStore.exportDof value', async () => { + uiStore.setExportDof(true); + mockStartGltfExport(); + uiStore.setView('3d'); + projectStore.applyView(staleView()); + render(Model3DViewer); + + await waitFor(() => + expect(workflow.startGltfExport).toHaveBeenCalledWith(true, { preview: true }), + ); + expect(workflow.startGltfExport).toHaveBeenCalledTimes(1); + }); + + it('does not fire when the view is not 3d', async () => { + vi.mocked(workflow.startGltfExport).mockResolvedValue(true); + projectStore.applyView(staleView()); + render(Model3DViewer); + + await flush(); + expect(workflow.startGltfExport).not.toHaveBeenCalled(); + }); + + it('does not fire when the project has no slices', async () => { + vi.mocked(workflow.startGltfExport).mockResolvedValue(true); + uiStore.setView('3d'); + projectStore.applyView(staleView({ slices: [] })); + render(Model3DViewer); + + await flush(); + expect(workflow.startGltfExport).not.toHaveBeenCalled(); + }); + + it('does not fire when the glTF is current (not stale)', async () => { + vi.mocked(workflow.startGltfExport).mockResolvedValue(true); + uiStore.setView('3d'); + projectStore.applyView( + staleView({ + exports: { gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, gltfStale: false, upscaled: false }, + }), + ); + render(Model3DViewer); + + await flush(); + expect(workflow.startGltfExport).not.toHaveBeenCalled(); + }); + + it('does not fire while another job is busy', async () => { + vi.mocked(workflow.startGltfExport).mockResolvedValue(true); + uiStore.setView('3d'); + projectStore.applyView(staleView()); + jobStore.begin('inpainting'); + render(Model3DViewer); + + await flush(); + expect(workflow.startGltfExport).not.toHaveBeenCalled(); + }); + + /** + * Fails the way the real `startGltfExport` does: the job store goes idle + * first, and the promise settles only after a further round trip (its + * trailing log refresh). + */ + function mockFailingExport(): void { + vi.mocked(workflow.startGltfExport).mockImplementation(async () => { + jobStore.begin('export-gltf'); + await flush(); + jobStore.end(); + await flush(); + return false; + }); + } + + it('shows the retry placeholder after a failed rebuild, without retrying on its own', async () => { + mockFailingExport(); + uiStore.setView('3d'); + projectStore.applyView(staleView()); + render(Model3DViewer); + + await waitFor(() => expect(screen.getByTestId('model-viewer-failed')).toBeInTheDocument()); + expect(screen.getByTestId('model-viewer-retry')).toBeInTheDocument(); + await flush(); + await flush(); + expect(workflow.startGltfExport).toHaveBeenCalledTimes(1); + }); + + it('retry calls startGltfExport again', async () => { + mockFailingExport(); + uiStore.setView('3d'); + projectStore.applyView(staleView()); + render(Model3DViewer); + + await waitFor(() => expect(screen.getByTestId('model-viewer-retry')).toBeInTheDocument()); + expect(workflow.startGltfExport).toHaveBeenCalledTimes(1); + + await fireEvent.click(screen.getByTestId('model-viewer-retry')); + + await waitFor(() => expect(workflow.startGltfExport).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByTestId('model-viewer-retry')).toBeInTheDocument()); + expect(workflow.startGltfExport).toHaveBeenCalledTimes(2); + }); + + it('shows a building placeholder while another job keeps the scene busy', async () => { + uiStore.setView('3d'); + projectStore.applyView(staleView()); + jobStore.begin('export-gltf'); + render(Model3DViewer); + + // isBusy() is already true, so the auto-rebuild effect never even + // calls startGltfExport - the placeholder reflects jobStore state alone. + await waitFor(() => + expect(screen.getByTestId('model-viewer-building')).toHaveTextContent('Building the 3D scene…'), + ); + expect(workflow.startGltfExport).not.toHaveBeenCalled(); + }); + }); + + describe('framing attributes from sceneProfile', () => { + it('computes camera-target, camera-orbit, field-of-view and max-camera-orbit from the scene profile', async () => { + const profile: SceneProfileView = { + cameraZ: 0, + pitch: 10, + halfFov: 20, + cards: [ + { index: 0, z: 100, top: 0, bottom: 10 }, + { index: 1, z: 300, top: 0, bottom: 10 }, + ], + ground: null, + }; + uiStore.setView('3d'); + projectStore.applyView( + makeView({ + exports: { gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, gltfStale: false, upscaled: false }, + sceneProfile: profile, + }), + ); + render(Model3DViewer); + + const expected = expectedFraming(profile); + await waitFor(() => { + const el = screen.getByTestId('model-viewer'); + expect(el).toHaveAttribute('camera-target', expected.target); + expect(el).toHaveAttribute('camera-orbit', expected.orbit); + expect(el).toHaveAttribute('max-camera-orbit', expected.maxOrbit); + expect(el).toHaveAttribute('field-of-view', expected.fov); + }); + }); + + it('falls back to auto framing without a sceneProfile', async () => { + uiStore.setView('3d'); + projectStore.applyView( + makeView({ + exports: { gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, gltfStale: false, upscaled: false }, + }), + ); + render(Model3DViewer); + + await waitFor(() => { + const el = screen.getByTestId('model-viewer'); + expect(el).toHaveAttribute('camera-target', 'auto auto auto'); + expect(el).toHaveAttribute('camera-orbit', '180deg 90deg auto'); + expect(el).toHaveAttribute('field-of-view', 'auto'); + }); + }); + }); }); diff --git a/frontend/src/lib/state/ui.svelte.ts b/frontend/src/lib/state/ui.svelte.ts index 2bdc68d..e288275 100644 --- a/frontend/src/lib/state/ui.svelte.ts +++ b/frontend/src/lib/state/ui.svelte.ts @@ -104,6 +104,9 @@ function createUiStore() { let inpainted = $state(false); let previewed = $state(false); let exported = $state(false); + // Depth of field for glTF exports: chosen in the Export dialog and reused + // when the 3D view rebuilds a stale scene. + let exportDof = $state(false); // Segment Anything (object) selection is the main way to make slices. let segmentationMode = $state('segment'); let depthModel = $state(DEFAULT_DEPTH_MODEL); @@ -253,6 +256,12 @@ function createUiStore() { markExported(): void { exported = true; }, + get exportDof(): boolean { + return exportDof; + }, + setExportDof(value: boolean): void { + exportDof = value; + }, get theme(): Theme { return theme; @@ -320,6 +329,7 @@ function createUiStore() { inpainted = false; previewed = false; exported = false; + exportDof = false; segmentationMode = 'segment'; depthModel = DEFAULT_DEPTH_MODEL; pendingNumSlices = DEFAULT_NUM_SLICES; diff --git a/frontend/src/lib/workflow.test.ts b/frontend/src/lib/workflow.test.ts index 7c8e858..e87c7fb 100644 --- a/frontend/src/lib/workflow.test.ts +++ b/frontend/src/lib/workflow.test.ts @@ -605,4 +605,82 @@ describe('workflow', () => { expect(toastStore.toasts[0]).toMatchObject({ kind: 'success', title: 'Depth map ready' }); }); }); + + describe('startGltfExport', () => { + function stubGltfFetch(succeed: boolean) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + const method = init?.method ?? 'GET'; + if (url === '/api/v1/projects/appstate-test/export/gltf' && method === 'POST') { + return jsonResponse(202, { job: { id: 'job-gltf', kind: 'export-gltf', status: 'queued', progress: 0 } }); + } + if (url === '/api/v1/jobs/job-gltf') { + return succeed + ? jsonResponse(200, { + id: 'job-gltf', + kind: 'export-gltf', + status: 'succeeded', + progress: 1, + project: makeView({ + revision: 2, + exports: { + gltf: { url: '/api/v1/projects/appstate-test/export/gltf?v=1' }, + gltfStale: false, + upscaled: false, + }, + }), + }) + : jsonResponse(200, { id: 'job-gltf', kind: 'export-gltf', status: 'failed', progress: 1, error: 'boom' }); + } + if (url.startsWith('/api/v1/projects/appstate-test/logs')) { + return jsonResponse(200, { entries: [], next: 0 }); + } + throw new Error(`Unexpected fetch: ${method} ${url}`); + }); + } + + it('a plain (non-preview) export marks the project exported and toasts success', async () => { + projectStore.applyView(makeView()); + vi.stubGlobal('fetch', stubGltfFetch(true)); + + const ok = await workflow.startGltfExport(true); + + expect(ok).toBe(true); + expect(uiStore.exported).toBe(true); + expect(toastStore.toasts.at(-1)).toMatchObject({ kind: 'success', title: 'glTF scene ready' }); + expect(projectStore.view?.exports.gltf?.url).toBe('/api/v1/projects/appstate-test/export/gltf?v=1'); + }); + + it('a preview export (3D view auto-rebuild) applies the result but does not mark exported or toast', async () => { + projectStore.applyView(makeView()); + vi.stubGlobal('fetch', stubGltfFetch(true)); + + const ok = await workflow.startGltfExport(false, { preview: true }); + + expect(ok).toBe(true); + expect(uiStore.exported).toBe(false); + expect(toastStore.toasts).toHaveLength(0); + expect(projectStore.view?.exports.gltf?.url).toBe('/api/v1/projects/appstate-test/export/gltf?v=1'); + }); + + it('a failed preview export resolves false and still does not mark exported', async () => { + projectStore.applyView(makeView()); + vi.stubGlobal('fetch', stubGltfFetch(false)); + + const ok = await workflow.startGltfExport(false, { preview: true }); + + expect(ok).toBe(false); + expect(uiStore.exported).toBe(false); + }); + + it('without a project, resolves false and makes no request', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const ok = await workflow.startGltfExport(false, { preview: true }); + + expect(ok).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/lib/workflow.ts b/frontend/src/lib/workflow.ts index c5feefd..2a2cd82 100644 --- a/frontend/src/lib/workflow.ts +++ b/frontend/src/lib/workflow.ts @@ -822,10 +822,18 @@ export async function toggleDarkMode(): Promise { await updateSettings({ darkMode: next === 'dark' }); } -/** Starts a glTF export job (`POST .../export/gltf`) and polls it to completion. */ -export async function startGltfExport(dof: boolean): Promise { +/** + * Starts a glTF export job (`POST .../export/gltf`) and polls it to + * completion. `preview` is the 3D view rebuilding a stale scene: no toast, + * and it does not count as the user exporting. Resolves to whether it + * succeeded. + */ +export async function startGltfExport( + dof: boolean, + { preview = false }: { preview?: boolean } = {}, +): Promise { const view = projectStore.view; - if (!view) return; + if (!view) return false; jobStore.begin('export-gltf'); try { const { job } = await api.startGltfExport(view.id, dof); @@ -833,10 +841,14 @@ export async function startGltfExport(dof: boolean): Promise { onProgress: (j) => jobStore.track(j), }); if (finished.project) projectStore.applyView(finished.project); - uiStore.markExported(); - toastStore.success('glTF scene ready'); + if (!preview) { + uiStore.markExported(); + toastStore.success('glTF scene ready'); + } + return true; } catch (err) { reportError(err); + return false; } finally { jobStore.end(); await refreshLogs(view.id); diff --git a/parallax_maker/api/projects.py b/parallax_maker/api/projects.py index 04c580c..f525eb6 100644 --- a/parallax_maker/api/projects.py +++ b/parallax_maker/api/projects.py @@ -20,6 +20,7 @@ from ..cancellation import OperationCancelled from ..controller import AppState, CompositeMode +from ..export_services import gltf_export_is_stale from ..ground_services import scene_profile from ..project_services import ( InvalidProjectFile, @@ -260,6 +261,9 @@ def _build_project_view( if gltf_path.exists() else None ), + gltf_stale=( + gltf_path.exists() and gltf_export_is_stale(state, Path(project_id)) + ), upscaled=any( Path(state.upscaled_filename(index)).exists() for index in range(len(state.image_slices)) diff --git a/parallax_maker/api/schemas.py b/parallax_maker/api/schemas.py index 8ae4bd4..19cae2a 100644 --- a/parallax_maker/api/schemas.py +++ b/parallax_maker/api/schemas.py @@ -148,6 +148,9 @@ class ProjectExportsView(ApiModel): """Content-versioned export availability (PARITY.md "Export/Render").""" gltf: AssetRef | None = None + #: The glTF export was built from an earlier version of the scene (slices, + #: camera or displacement changed since), or predates this check. + gltf_stale: bool = False upscaled: bool = False diff --git a/parallax_maker/export_services.py b/parallax_maker/export_services.py index ab612f7..a6d1e48 100644 --- a/parallax_maker/export_services.py +++ b/parallax_maker/export_services.py @@ -19,6 +19,8 @@ from __future__ import annotations +import hashlib +import json from dataclasses import dataclass from pathlib import Path from typing import Protocol @@ -41,6 +43,65 @@ DEFAULT_PUSH_DISTANCE_FACTOR = 0.75 +#: Next to ``AppState.MODEL_FILE``: the fingerprint of the scene the glTF +#: file was built from, so a stale export can be told apart from a current one. +GLTF_SOURCE_FILE = "model_source.json" + + +def _file_version(path: Path) -> list[int] | None: + try: + stat = path.stat() + except OSError: + return None + return [stat.st_mtime_ns, stat.st_size] + + +def gltf_scene_fingerprint(state: AppState, displacement_scale: float) -> str: + """A digest of everything a glTF export of ``state`` is built from. + + The slices (file identity, depth, ground flag), their upscaled textures and + - when displaced - depth maps, the camera, and the displacement. Any edit + that changes the exported scene changes the fingerprint. + """ + slices = [] + for index, image_slice in enumerate(state.image_slices): + slices.append( + { + "file": str(image_slice.filename), + "version": _file_version(Path(image_slice.filename)), + "depth": image_slice.depth, + "ground": image_slice.is_ground_plane, + "upscaled": _file_version(Path(state.upscaled_filename(index))), + "depthMap": ( + _file_version(Path(state.depth_filename(index))) + if displacement_scale > 0 + else None + ), + } + ) + source = { + "slices": slices, + "camera": state.camera.to_json(), + "displacement": displacement_scale, + } + encoded = json.dumps(source, sort_keys=True, default=float).encode() + return hashlib.sha256(encoded).hexdigest() + + +def gltf_export_is_stale(state: AppState, state_dir: Path) -> bool: + """Whether ``state_dir``'s glTF export no longer matches ``state``. + + An export without a recorded fingerprint (made before fingerprints were + recorded) counts as stale. + """ + try: + recorded = json.loads((state_dir / GLTF_SOURCE_FILE).read_text()) + except (OSError, ValueError): + return True + current = gltf_scene_fingerprint(state, state.mesh_displacement) + return recorded.get("fingerprint") != current + + class ExportServiceError(Exception): """Base class for export/render domain failures.""" @@ -178,6 +239,10 @@ def export_gltf(self, command: ExportGltf) -> ExportedGltfResult: inline_images=command.inline_images, support_dof=command.support_dof, ) + fingerprint = gltf_scene_fingerprint(state, command.displacement_scale) + (Path(command.state_id) / GLTF_SOURCE_FILE).write_text( + json.dumps({"fingerprint": fingerprint, "dof": command.support_dof}) + ) return ExportedGltfResult( state_id=command.state_id, diff --git a/parallax_maker/test_api_project_export.py b/parallax_maker/test_api_project_export.py index 0eca879..638a0a4 100644 --- a/parallax_maker/test_api_project_export.py +++ b/parallax_maker/test_api_project_export.py @@ -283,6 +283,131 @@ def test_gltf_export_with_dof_uses_mask_alpha_mode(client) -> None: assert scene["materials"][0]["alphaCutoff"] == 0.5 +# --- Export: glTF staleness (gltfStale / model_source.json sidecar) ------------ + + +def test_gltf_export_is_not_stale_and_records_dof_right_after_export(client) -> None: + view = _restore_fixture(client) + project_id = view["id"] + + job = _export_gltf(client, project_id, dof=True) + assert job["project"]["exports"]["gltf"] is not None + assert job["project"]["exports"]["gltfStale"] is False + + current = client.get(f"/api/v1/projects/{project_id}").get_json() + assert current["exports"]["gltf"] is not None + assert current["exports"]["gltfStale"] is False + + sidecar = json.loads((Path(project_id) / "model_source.json").read_text()) + assert sidecar["dof"] is True + assert isinstance(sidecar["fingerprint"], str) and sidecar["fingerprint"] + + +def test_gltf_export_records_dof_false_when_not_requested(client) -> None: + view = _restore_fixture(client) + project_id = view["id"] + + _export_gltf(client, project_id, dof=False) + + sidecar = json.loads((Path(project_id) / "model_source.json").read_text()) + assert sidecar["dof"] is False + + +def test_gltf_stale_becomes_true_after_changing_a_slice_depth(client) -> None: + view = _restore_fixture(client) + project_id = view["id"] + _export_gltf(client, project_id) + + depth_response = client.put( + f"/api/v1/projects/{project_id}/slices/0/depth", json={"depth": 5} + ) + assert depth_response.status_code == 200 + assert depth_response.get_json()["exports"]["gltfStale"] is True + + # ... and the project view keeps reporting it stale afterwards, too. + current = client.get(f"/api/v1/projects/{project_id}").get_json() + assert current["exports"]["gltfStale"] is True + + +def test_gltf_stale_becomes_true_after_changing_the_camera(client) -> None: + view = _restore_fixture(client) + project_id = view["id"] + _export_gltf(client, project_id) + + camera = view["settings"]["camera"] + settings_response = client.put( + f"/api/v1/projects/{project_id}/settings", + json={ + "camera": { + "distance": camera["distance"], + "focalLength": camera["focalLength"] + 25.0, + "maxDistance": camera["maxDistance"], + } + }, + ) + assert settings_response.status_code == 200 + assert settings_response.get_json()["exports"]["gltfStale"] is True + + +def test_gltf_stale_becomes_true_after_changing_the_camera_pitch(client) -> None: + view = _restore_fixture(client) + project_id = view["id"] + _export_gltf(client, project_id) + + camera = view["settings"]["camera"] + settings_response = client.put( + f"/api/v1/projects/{project_id}/settings", + json={ + "camera": { + "distance": camera["distance"], + "focalLength": camera["focalLength"], + "maxDistance": camera["maxDistance"], + "pitch": 5.0, + } + }, + ) + assert settings_response.status_code == 200 + assert settings_response.get_json()["exports"]["gltfStale"] is True + + +def test_gltf_stale_becomes_true_after_rewriting_a_slice_file(client) -> None: + view = _restore_fixture(client) + project_id = view["id"] + _export_gltf(client, project_id) + + # Re-inpaint-style rewrite of a slice file in place: same filename, new + # bytes (different mtime/size), without touching the in-memory state. + slice_path = Path(project_id) / "image_slice_0.png" + assert slice_path.exists() + Image.new("RGBA", (7, 7), (9, 9, 9, 255)).save(slice_path) + + current = client.get(f"/api/v1/projects/{project_id}").get_json() + assert current["exports"]["gltfStale"] is True + + +def test_gltf_without_a_sidecar_is_stale(client) -> None: + view = _restore_fixture(client) + project_id = view["id"] + _export_gltf(client, project_id) + + (Path(project_id) / "model_source.json").unlink() + + current = client.get(f"/api/v1/projects/{project_id}").get_json() + assert current["exports"]["gltf"] is not None + assert current["exports"]["gltfStale"] is True + + +def test_no_gltf_export_reports_gltf_none_and_not_stale(client) -> None: + from ._api_test_helpers import upload_fixture_image + + view = upload_fixture_image(client) + project_id = view["id"] + + current = client.get(f"/api/v1/projects/{project_id}").get_json() + assert current["exports"]["gltf"] is None + assert current["exports"]["gltfStale"] is False + + class _FakeUpscaler: """Deterministic 2x nearest-neighbour upscaler (mirrors ``e2e_support.fakes.FakeUpscaler``, reimplemented here since diff --git a/parallax_maker/test_export_services.py b/parallax_maker/test_export_services.py index 8c46784..53b6136 100644 --- a/parallax_maker/test_export_services.py +++ b/parallax_maker/test_export_services.py @@ -12,12 +12,15 @@ from .controller import AppState from .export_services import ( + GLTF_SOURCE_FILE, ExportGltf, ExportNotReady, ExportService, InvalidSliceIndex, RenderAnimation, UpscaleTextures, + gltf_export_is_stale, + gltf_scene_fingerprint, ) from .slice import ImageSlice @@ -93,14 +96,18 @@ def test_export_gltf_without_displacement_writes_a_scene(tmp_path, monkeypatch): assert scene["asset"]["version"] == "2.0" assert len(scene["meshes"]) == 2 assert len(scene["images"]) == 2 - assert all(image["uri"].startswith("data:image/png;base64,") for image in scene["images"]) + assert all( + image["uri"].startswith("data:image/png;base64,") for image in scene["images"] + ) # No displacement: each card is an un-subdivided flat quad (4 corners). # accessors[1] is the first mesh's POSITION accessor (see create_card: # tex-coord accessor is appended before the vertex accessor). assert scene["accessors"][1]["count"] == 4 -def test_export_gltf_with_displacement_generates_missing_depth_maps(tmp_path, monkeypatch): +def test_export_gltf_with_displacement_generates_missing_depth_maps( + tmp_path, monkeypatch +): monkeypatch.chdir(tmp_path) state = make_state(tmp_path, "appstate-gltf-displaced") Path(state.filename).mkdir() @@ -153,6 +160,98 @@ def test_export_gltf_requires_at_least_one_slice(tmp_path, monkeypatch): service.export_gltf(ExportGltf(state_id=state.filename)) +# --- gltf staleness fingerprint --------------------------------------------------- + + +def test_export_gltf_writes_a_fingerprint_sidecar_recording_dof(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + state = make_state(tmp_path, "appstate-gltf-sidecar") + Path(state.filename).mkdir() + service = make_service(state) + + service.export_gltf( + ExportGltf(state_id=state.filename, displacement_scale=0.0, support_dof=True) + ) + + sidecar_path = Path(state.filename) / GLTF_SOURCE_FILE + assert sidecar_path.exists() + sidecar = json.loads(sidecar_path.read_text()) + assert sidecar["dof"] is True + assert sidecar["fingerprint"] == gltf_scene_fingerprint(state, 0.0) + assert not gltf_export_is_stale(state, Path(state.filename)) + + +def test_export_gltf_records_dof_false_when_not_requested(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + state = make_state(tmp_path, "appstate-gltf-sidecar-nodof") + Path(state.filename).mkdir() + service = make_service(state) + + service.export_gltf( + ExportGltf(state_id=state.filename, displacement_scale=0.0, support_dof=False) + ) + + sidecar = json.loads((Path(state.filename) / GLTF_SOURCE_FILE).read_text()) + assert sidecar["dof"] is False + + +def test_gltf_export_is_stale_without_a_sidecar(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + state = make_state(tmp_path, "appstate-gltf-no-sidecar") + Path(state.filename).mkdir() + + # No export has happened yet, so there is no sidecar at all. + assert gltf_export_is_stale(state, Path(state.filename)) + + +def test_gltf_export_is_stale_after_a_slice_depth_change(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + state = make_state(tmp_path, "appstate-gltf-depth-change") + Path(state.filename).mkdir() + service = make_service(state) + + service.export_gltf(ExportGltf(state_id=state.filename, displacement_scale=0.0)) + assert not gltf_export_is_stale(state, Path(state.filename)) + + state.image_slices[0].depth = 200 + + assert gltf_export_is_stale(state, Path(state.filename)) + + +def test_gltf_export_is_stale_after_rewriting_a_slice_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + state = make_state(tmp_path, "appstate-gltf-file-rewrite") + Path(state.filename).mkdir() + service = make_service(state) + + service.export_gltf(ExportGltf(state_id=state.filename, displacement_scale=0.0)) + assert not gltf_export_is_stale(state, Path(state.filename)) + + # Rewrite the first slice's file in place (same name, different content - + # e.g. a re-inpaint): mtime/size change even though nothing on the + # in-memory ``state`` object was touched. + filename = Path(state.image_slices[0].filename) + image = Image.new("RGBA", (10, 10), (1, 2, 3, 255)) + image.save(filename) + + assert gltf_export_is_stale(state, Path(state.filename)) + + +def test_gltf_scene_fingerprint_changes_with_camera_and_displacement( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + state = make_state(tmp_path, "appstate-gltf-fingerprint") + Path(state.filename).mkdir() + + baseline = gltf_scene_fingerprint(state, 0.0) + + assert gltf_scene_fingerprint(state, 10.0) != baseline + + state.camera.pitch = 5.0 + assert gltf_scene_fingerprint(state, 0.0) != baseline + + # --- upscale_textures ------------------------------------------------------------