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
10 changes: 10 additions & 0 deletions client/public/ar-quick-look.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
176 changes: 176 additions & 0 deletions client/src/components/media/ArExportPanel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useCallback, useMemo, useState } from 'react';
import { AlertTriangle, Box, Loader2, QrCode, Smartphone } from 'lucide-react';
import { imageTo3dUsdzUrl, uploadImageTo3dUsdz } from '../../services/api';
import {
AR_TRIANGLE_BUDGET,
countSceneTriangles,
exportSceneToUsdz,
supportsArQuickLook,
} from '../../lib/usdzExport.js';
import { generateQrCodeSvg } from '../../lib/qrCode';
import useMounted from '../../hooks/useMounted';
import { formatBytes } from '../../utils/formatters';
import toast from '../ui/Toast';

/**
* "Export for AR" on `/3d/:id` — the step that takes a generated mesh off the
* screen and into the user's actual room (#5756).
*
* The conversion happens HERE, in the browser, because the viewer has already
* parsed the GLB and decoded its textures; the server only stores the result. The
* bytes are persisted rather than handed back as a blob URL: AR Quick Look does
* not reliably open a blob, and a blob does not survive a reload — so a record
* exported once is re-served on every later visit instead of re-exported.
*
* The affordance is deliberately NOT the same on every device. Only Safari on
* iOS/iPadOS implements the `<a rel="ar">` handoff, so that is the only place a
* "View in AR" label is honest; everywhere else this is a plain `.usdz` download
* plus a QR code, which is the bridge from "I generated this at my desk" to "it is
* now on my floor".
*/
export default function ArExportPanel({ record, scene, onRecordChange }) {
const mountedRef = useMounted();
const [busy, setBusy] = useState(false);

// Resolved once per mount rather than per render: it is a static browser
// capability, and re-probing it on every keystroke elsewhere on the page is
// pointless DOM work.
const canQuickLook = useMemo(() => supportsArQuickLook(), []);
const usdzUrl = imageTo3dUsdzUrl(record.id);
// AR Quick Look needs a real URL and the QR code needs an absolute one — the
// install's own origin as this browser reached it, which is already the
// Tailscale HTTPS host when the page was opened that way.
const absoluteUsdzUrl = typeof window === 'undefined'
? usdzUrl
: new URL(usdzUrl, window.location.origin).toString();

const exported = Boolean(record.usdzPath);
const canExport = record.status === 'ready' && Boolean(record.assetPath);

const handleExport = useCallback(async () => {
if (busy || !scene) return;
setBusy(true);
// Yield a frame so the spinner actually paints: `parseAsync` is async but its
// work is CPU-bound and long enough on a textured mesh to freeze a button that
// never got a chance to re-render.
await new Promise((resolve) => { requestAnimationFrame(() => resolve()); });

const triangles = countSceneTriangles(scene);
if (triangles > AR_TRIANGLE_BUDGET) {
// Say so rather than shipping a file that opens to a blank room. Not a
// refusal — the export still runs — but the count and the remedy are named,
// because the only real fix is a lighter render, not a lighter export.
toast(
`This mesh is ${triangles.toLocaleString()} triangles — above the `
+ `${AR_TRIANGLE_BUDGET.toLocaleString()} that opens comfortably in AR. `
+ 'Re-render at a lower Quality tier for a lighter AR file.',
{ icon: '⚠️' },
);
}

const bytes = await exportSceneToUsdz(scene).catch((err) => {
toast.error(err?.message || 'Could not convert this model to USDZ.');
return null;
});
if (!bytes) {
if (mountedRef.current) setBusy(false);
return;
}

const next = await uploadImageTo3dUsdz(record.id, bytes, { silent: true }).catch((err) => {
toast.error(err?.message || 'Could not save the AR export.');
return null;
});
if (!mountedRef.current) return;
setBusy(false);
if (next) {
onRecordChange(next);
toast.success(`AR export ready (${formatBytes(bytes.byteLength)}).`);
}
}, [busy, scene, record.id, onRecordChange, mountedRef]);

if (!canExport) return null;

return (
<section className="mt-4 rounded-lg border border-port-border bg-port-card p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Box className="h-4 w-4 text-port-accent" />
<h2 className="text-sm font-semibold text-white">Augmented reality</h2>
</div>
<button
type="button"
onClick={handleExport}
disabled={busy || !scene}
title={scene ? undefined : 'Waiting for the model to finish loading'}
className="inline-flex min-h-[44px] items-center gap-1.5 rounded-md border border-port-border px-3 py-1.5 text-xs text-gray-300 hover:border-port-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Box className="h-3.5 w-3.5" />}
{busy ? 'Exporting…' : exported ? 'Re-export for AR (.usdz)' : 'Export for AR (.usdz)'}
</button>
</div>

{!exported && (
<p className="mt-2 text-xs text-gray-500">
Converts the mesh you are looking at to USDZ so it can be placed at real scale in
your room — no app install, no upload to anyone else.
</p>
)}

{exported && canQuickLook && (
<div className="mt-2 flex items-center gap-3">
{/* AR Quick Look engages off an `<a rel="ar">` whose ONLY child is an
`<img>` — Safari treats that image as the poster it badges and taps
through. A text node inside the anchor breaks the handoff, so the
label is a sibling and the anchor carries its own accessible name. */}
<a
rel="ar"
href={usdzUrl}
aria-label="View in AR"
className="inline-block min-h-[44px] min-w-[44px] rounded-md bg-port-accent p-2.5 text-white hover:bg-blue-600"
>
<img src="/ar-quick-look.svg" alt="View in AR" width="24" height="24" className="h-6 w-6" />
</a>
<div className="text-xs text-gray-400">
<p className="font-medium text-white">View in AR</p>
<p>Opens in your room at real scale.</p>
</div>
</div>
)}

{exported && !canQuickLook && (
<div className="mt-2 space-y-3">
<div className="flex items-start gap-1.5 text-xs text-gray-400">
<Smartphone className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
AR placement needs Safari on an iPhone or iPad. Scan this from your phone to
open the model in your room, or download the file below.
</span>
</div>
<div className="flex flex-wrap items-center gap-3">
<div
className="rounded-xl border border-port-border bg-white p-2 shadow"
role="img"
aria-label="QR code linking to the AR model"
dangerouslySetInnerHTML={{ __html: generateQrCodeSvg(absoluteUsdzUrl, { size: 140 }) }}
/>
<a
href={usdzUrl}
className="inline-flex items-center gap-1.5 text-xs text-gray-400 underline decoration-dotted hover:text-gray-200"
>
<QrCode className="h-3.5 w-3.5" />
Download AR model (.usdz)
</a>
</div>
</div>
)}

{exported && (
<p className="mt-2 flex items-start gap-1.5 text-xs text-gray-500">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>Re-rendering this model clears its AR export — the file would no longer match the mesh.</span>
</p>
)}
</section>
);
}
147 changes: 147 additions & 0 deletions client/src/components/media/ArExportPanel.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { Group } from 'three';
import ArExportPanel from './ArExportPanel';

const uploadImageTo3dUsdz = vi.fn();
vi.mock('../../services/api', () => ({
imageTo3dUsdzUrl: (id) => `/api/image-to-3d/models/${id}/usdz`,
uploadImageTo3dUsdz: (...a) => uploadImageTo3dUsdz(...a),
}));

// The real exporter pulls three's USDZ writer and a zip library and walks GPU
// textures — none of which jsdom can run. What this suite covers is the contract
// AROUND it: what gets exported, what gets persisted, and which affordance each
// browser is offered. `supportsArQuickLook` is the switch that decides the last
// one, so it is the only thing stubbed per-test.
const usdz = vi.hoisted(() => ({ quickLook: false, bytes: null, error: null, triangles: 1000 }));
vi.mock('../../lib/usdzExport.js', async (importOriginal) => ({
...(await importOriginal()),
supportsArQuickLook: () => usdz.quickLook,
countSceneTriangles: () => usdz.triangles,
exportSceneToUsdz: vi.fn(async () => {
if (usdz.error) throw usdz.error;
return usdz.bytes;
}),
}));
vi.mock('../../lib/qrCode', () => ({
generateQrCodeSvg: (text) => `<svg data-qr="${text}"></svg>`,
}));
const toast = vi.hoisted(() => Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }));
vi.mock('../ui/Toast', () => ({ default: toast }));

const record = (over = {}) => ({
id: 'image3d-1',
name: 'Example Beacon',
status: 'ready',
assetPath: '/data/image-to-3d/image3d-1/model.glb',
usdzPath: null,
...over,
});

beforeEach(() => {
vi.clearAllMocks();
usdz.quickLook = false;
usdz.error = null;
usdz.triangles = 1000;
usdz.bytes = new ArrayBuffer(2048);
});

describe('ArExportPanel', () => {
// A record with no mesh has nothing to convert — offering the action anyway
// produces a button whose only outcome is a server 409.
it('renders nothing until the record has a rendered mesh', () => {
const { container } = render(
<ArExportPanel record={record({ status: 'generating', assetPath: null })} scene={new Group()} onRecordChange={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});

// The scene arrives asynchronously from the viewer; exporting before it lands
// would throw inside the exporter instead of just waiting.
it('disables the export until the viewer hands over the loaded scene', () => {
render(<ArExportPanel record={record()} scene={null} onRecordChange={vi.fn()} />);
expect(screen.getByRole('button', { name: /export for ar/i })).toBeDisabled();
});

it('exports the loaded scene and persists the bytes', async () => {
const onRecordChange = vi.fn();
const exported = record({ usdzPath: '/data/image-to-3d/image3d-1/model.usdz' });
uploadImageTo3dUsdz.mockResolvedValue(exported);
render(<ArExportPanel record={record()} scene={new Group()} onRecordChange={onRecordChange} />);

fireEvent.click(screen.getByRole('button', { name: /export for ar/i }));
await waitFor(() => expect(uploadImageTo3dUsdz).toHaveBeenCalled());
const [id, bytes, options] = uploadImageTo3dUsdz.mock.calls[0];
expect(id).toBe('image3d-1');
expect(bytes).toBe(usdz.bytes);
// The component owns its own error toast, so the request must not raise a
// second one from apiCore.
expect(options).toMatchObject({ silent: true });
expect(onRecordChange).toHaveBeenCalledWith(exported);
});

// A mesh far above the AR budget still exports — but silently handing back a
// file that opens to an empty room is the failure this warning exists to
// prevent, and the count is the only thing the user can act on.
it('warns with the triangle count when the mesh is above the AR budget', async () => {
usdz.triangles = 900_000;
uploadImageTo3dUsdz.mockResolvedValue(record({ usdzPath: '/x.usdz' }));
render(<ArExportPanel record={record()} scene={new Group()} onRecordChange={vi.fn()} />);

fireEvent.click(screen.getByRole('button', { name: /export for ar/i }));
await waitFor(() => expect(uploadImageTo3dUsdz).toHaveBeenCalled());
expect(toast).toHaveBeenCalledWith(expect.stringContaining('900,000'), expect.anything());
});

it('surfaces a conversion failure without persisting anything', async () => {
usdz.error = new Error('USDZ does not support negative scales');
render(<ArExportPanel record={record()} scene={new Group()} onRecordChange={vi.fn()} />);

fireEvent.click(screen.getByRole('button', { name: /export for ar/i }));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('negative scales')));
expect(uploadImageTo3dUsdz).not.toHaveBeenCalled();
});

// The stored artifact is the whole reason the bytes are persisted rather than
// handed back as a blob: revisiting the record must reuse it, never silently
// re-run a multi-second conversion.
it('reuses a stored export instead of re-exporting on load', async () => {
render(
<ArExportPanel record={record({ usdzPath: '/data/image-to-3d/image3d-1/model.usdz' })} scene={new Group()} onRecordChange={vi.fn()} />,
);
expect(uploadImageTo3dUsdz).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: /re-export for ar/i })).toBeInTheDocument();
});

// Labelling a desktop button "View in AR" is a promise the browser cannot keep,
// so the affordance has to differ — a QR bridge to the phone plus a plain
// download, and NO AR anchor.
it('offers a QR bridge and a download where AR Quick Look is unavailable', () => {
render(
<ArExportPanel record={record({ usdzPath: '/data/image-to-3d/image3d-1/model.usdz' })} scene={new Group()} onRecordChange={vi.fn()} />,
);
expect(screen.queryByRole('link', { name: /view in ar/i })).toBeNull();
expect(screen.getByRole('link', { name: /download ar model/i }))
.toHaveAttribute('href', '/api/image-to-3d/models/image3d-1/usdz');
// Absolute, because the QR is scanned by a DIFFERENT device — a relative path
// would resolve against whatever the phone happens to have open.
expect(screen.getByLabelText('QR code linking to the AR model').innerHTML)
.toContain('http://localhost:3000/api/image-to-3d/models/image3d-1/usdz');
});

it('renders the rel="ar" anchor on a browser that implements AR Quick Look', () => {
usdz.quickLook = true;
render(
<ArExportPanel record={record({ usdzPath: '/data/image-to-3d/image3d-1/model.usdz' })} scene={new Group()} onRecordChange={vi.fn()} />,
);
const anchor = screen.getByRole('link', { name: 'View in AR' });
expect(anchor).toHaveAttribute('rel', 'ar');
expect(anchor).toHaveAttribute('href', '/api/image-to-3d/models/image3d-1/usdz');
// Safari only engages the handoff when the anchor's only child is an <img>;
// a text node inside it silently turns this back into a download.
expect(anchor.children).toHaveLength(1);
expect(anchor.firstElementChild.tagName).toBe('IMG');
expect(screen.queryByLabelText('QR code linking to the AR model')).toBeNull();
});
});
17 changes: 15 additions & 2 deletions client/src/components/media/GlbViewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function cloneGlbSceneWithOpaqueMaterials(scene) {
return clone;
}

function GlbModel({ src, forceOpaque }) {
function GlbModel({ src, forceOpaque, onSceneLoaded }) {
// `useGLTF` keys drei's global cache on the URL, so a new generation (a new
// `src`) parses fresh while revisiting the same mesh reuses the cache — no
// manual cache-clear needed (clearing on unmount would force a full multi-MB
Expand All @@ -74,6 +74,15 @@ function GlbModel({ src, forceOpaque }) {
});
};
}, [forceOpaque, renderedScene]);
// Hand the DISPLAYED graph (post force-opaque clone) to the parent, so a
// consumer that re-serializes it — the AR/USDZ export — ships what the user is
// actually looking at rather than a differently-materialed original. Cleared on
// unmount/src change because the effect above disposes this clone's materials:
// a retained handle would then serialize a scene whose textures are gone.
useEffect(() => {
onSceneLoaded?.(renderedScene);
return () => onSceneLoaded?.(null);
}, [onSceneLoaded, renderedScene]);
return <GltfPrimitive object={renderedScene} />;
}

Expand Down Expand Up @@ -163,6 +172,10 @@ export default function GlbViewer({
className = '',
forceOpaque = false,
initialBackground = DEFAULT_BACKGROUND,
// Called with the loaded three.js object graph once the GLB parses, and with
// `null` when it unloads. Must be referentially stable (a `useState` setter or a
// `useCallback`) — an inline arrow re-fires the effect on every render.
onSceneLoaded,
}) {
const backgroundInputId = useId();
const controlsPanelId = useId();
Expand Down Expand Up @@ -270,7 +283,7 @@ export default function GlbViewer({
</ErrorBoundary>
<Suspense fallback={null}>
<Bounds fit clip observe margin={1.2}>
<GlbModel src={src} forceOpaque={forceOpaque} />
<GlbModel src={src} forceOpaque={forceOpaque} onSceneLoaded={onSceneLoaded} />
</Bounds>
</Suspense>
<OrbitControls makeDefault enablePan enableZoom enableRotate />
Expand Down
Loading