From 16a90dead176eba5851e9f9ad9906ac7c71df954 Mon Sep 17 00:00:00 2001 From: provos Date: Sun, 27 Sep 2026 16:40:09 -0700 Subject: [PATCH] fix(3d): rebuild a stale glTF scene when entering the 3D view The 3D view showed whatever model.gltf was last exported, even after the slices, camera or displacement changed - a project could show a scene missing slices it has since gained. Exports now record a fingerprint of the scene they were built from (model_source.json); the project view reports exports.gltfStale, and the 3D view rebuilds a missing or stale export before showing it (Export dialog's DOF choice kept, no toast). A failed rebuild offers "Try again" instead of looping. The viewer also opens at the reference camera (target, orbit and field of view from the scene profile) instead of Dash's fixed zoomed-in orbit, which cropped much of the scene, and no longer auto-rotates. Co-Authored-By: Claude Opus 5.5 --- e2e/drivers/svelte.ts | 17 ++ e2e/drivers/types.ts | 5 + e2e/project-export.spec.ts | 16 ++ frontend/src/lib/api/generated.ts | 2 + frontend/src/lib/api/schema.json | 5 + .../components/dialogs/ExportDialog.svelte | 14 +- .../components/dialogs/ExportDialog.test.ts | 43 ++- .../components/viewer/Model3DViewer.svelte | 105 ++++++- .../components/viewer/Model3DViewer.test.ts | 258 +++++++++++++++++- frontend/src/lib/state/ui.svelte.ts | 10 + frontend/src/lib/workflow.test.ts | 78 ++++++ frontend/src/lib/workflow.ts | 22 +- parallax_maker/api/projects.py | 4 + parallax_maker/api/schemas.py | 3 + parallax_maker/export_services.py | 65 +++++ parallax_maker/test_api_project_export.py | 125 +++++++++ parallax_maker/test_export_services.py | 103 ++++++- 17 files changed, 841 insertions(+), 34 deletions(-) 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 ------------------------------------------------------------