Skip to content
Open
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
17 changes: 17 additions & 0 deletions e2e/drivers/svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Download> {
await this.closeDialogs();
await this.ensureSelected(index);
Expand Down
5 changes: 5 additions & 0 deletions e2e/drivers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,4 +226,9 @@ export interface UiDriver {
upscaleTextures(): Promise<void>;
/** Clicks a slice thumbnail's download icon and returns the resulting browser download. */
downloadSlice(index: number): Promise<Download>;
/**
* 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 }>;
}
16 changes: 16 additions & 0 deletions e2e/project-export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/lib/api/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -417,6 +418,7 @@ export interface ProjectAssets {
*/
export interface ProjectExportsView {
gltf?: AssetRef | null;
gltfStale?: Gltfstale;
upscaled?: Upscaled;
}
/**
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/lib/api/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -866,6 +866,11 @@
],
"default": null
},
"gltfStale": {
"default": false,
"title": "Gltfstale",
"type": "boolean"
},
"upscaled": {
"default": false,
"title": "Upscaled",
Expand Down
14 changes: 7 additions & 7 deletions frontend/src/lib/components/dialogs/ExportDialog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -47,7 +47,7 @@
// `<a download>` click (not `window.open`), per the migration task.
async function exportGltfScene(): Promise<void> {
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');
}
Expand Down Expand Up @@ -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
</label>
Expand Down
43 changes: 42 additions & 1 deletion frontend/src/lib/components/dialogs/ExportDialog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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 <a download> click, not window.open', async () => {
Expand Down
105 changes: 91 additions & 14 deletions frontend/src/lib/components/viewer/Model3DViewer.svelte
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
<script lang="ts">
/**
* The left-panel "3D" viewer tab: renders `<model-viewer>` pointing at the
* project's exported glTF asset, exactly like Dash's `get_gltf_iframe`
* (`parallax_maker/utils.py`) - same camera/AR/controls attributes, just
* as a real custom element instead of an `<iframe srcDoc>`. Placeholder
* text (`get_no_gltf_available`'s own wording) when no scene has been
* exported yet.
* The 3D view: `<model-viewer>` showing the project's glTF export.
*
* The export must match the current scene, so entering the view while it is
* missing or stale (`exports.gltfStale`: slices, camera or displacement
* changed since it was built) rebuilds it first. The camera starts at the
* reference camera, so the scene first looks like the image, and orbits
* around the middle of the card stack.
*
* `@google/model-viewer` is bundled from npm (no CDN, so this works
* offline in e2e) and lazy-loaded here - only when this tab is actually
* opened - so the main bundle stays small (see the migration task's
* bundle-size requirement).
* offline in e2e) and lazy-loaded here - only when the view is actually
* opened - so the main bundle stays small.
*/
import { untrack } from 'svelte';
import { projectStore } from '../../state/project.svelte';
import { uiStore } from '../../state/ui.svelte';
import { jobStore } from '../../state/jobs.svelte';
import { isBusy } from '../../state/busy.svelte';
import * as workflow from '../../workflow';

let ready = $state(false);
let loadFailed = $state(false);
Expand All @@ -34,28 +38,94 @@
});

const gltfUrl = $derived(projectStore.view?.exports.gltf?.url ?? null);
const needsBuild = $derived.by(() => {
const view = projectStore.view;
if (!view || view.slices.length === 0) return false;
return !view.exports.gltf || !!view.exports.gltfStale;
});
const building = $derived(jobStore.active === 'export-gltf');
// The revision a rebuild failed at: no automatic retry until the scene
// changes again (or the user asks).
let failedRevision = $state<number | null>(null);
const failed = $derived(failedRevision !== null && failedRevision === projectStore.view?.revision);
// Covers the whole export call: the job store is already idle while the
// call finishes up, before its outcome is known.
let pending = $state(false);

function rebuild(): void {
const revision = projectStore.view?.revision ?? null;
pending = true;
void workflow.startGltfExport(uiStore.exportDof, { preview: true }).then((ok) => {
failedRevision = ok ? null : revision;
pending = false;
});
}

$effect(() => {
if (uiStore.view !== '3d' || !needsBuild || failed || pending || isBusy()) return;
untrack(rebuild);
});

function retry(): void {
if (pending || isBusy()) return;
failedRevision = null;
rebuild();
}

// glTF is y-up (the scene's y points down) and the reference camera sits at
// (0, 0, cameraZ) looking along +z, tilted up by the pitch.
const framing = $derived.by(() => {
const profile = projectStore.view?.sceneProfile;
if (!profile) return null;
const depths = profile.cards.map((card) => card.z);
const middle = depths.length
? (Math.min(...depths) + Math.max(...depths)) / 2
: (projectStore.view?.settings.camera.maxDistance ?? 0) / 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`,
};
});
</script>

<div class="model3d" data-testid="model-viewer-container">
{#if loadFailed}
<p class="placeholder" data-testid="model-viewer-error">Failed to load the 3D viewer.</p>
{:else if !ready}
<p class="placeholder" data-testid="model-viewer-loading">Loading 3D viewer…</p>
{:else if needsBuild && failed && !building && !pending}
<div class="placeholder" data-testid="model-viewer-failed">
<p>Couldn't build the 3D scene.</p>
<button type="button" class="btn" data-testid="model-viewer-retry" disabled={isBusy()} onclick={retry}>
Try again
</button>
</div>
{:else if needsBuild}
<p class="placeholder" data-testid="model-viewer-building">
{building || !isBusy()
? 'Building the 3D scene…'
: 'The 3D scene will update when the current job finishes.'}
</p>
{:else if gltfUrl}
<!-- svelte-ignore element_invalid_self_closing_tag -->
<model-viewer
data-testid="model-viewer"
src={gltfUrl}
alt="glTF Scene"
ar
auto-rotate
camera-target="0m 0m 0m"
camera-orbit="3.106650330236851rad 1.5658376358588284rad 50m"
field-of-view="8"
camera-target={framing?.target ?? 'auto auto auto'}
camera-orbit={framing?.orbit ?? '180deg 90deg auto'}
field-of-view={framing?.fov ?? 'auto'}
min-camera-orbit="auto auto 1%"
max-camera-orbit="auto auto 100%"
max-camera-orbit={framing?.maxOrbit ?? 'auto auto 100%'}
min-field-of-view="1deg"
max-field-of-view="60deg"
interaction-prompt="none"
camera-controls
touch-action="pan-y"
class="viewer-el"
Expand All @@ -78,6 +148,13 @@
color: var(--color-text-muted);
}

div.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}

.viewer-el {
width: 100%;
height: 100%;
Expand Down
Loading
Loading