Share meshes as self-contained URLs
Copy share link packs the original uploaded file (compressed, in its
original format) plus export format and view mode into the #share= URL
fragment. Opening the link restores the mesh and viewer state; links
past 65,000 characters report the mesh as too large to share by URL.
6 changed files+320−15
README.mdmodified+9−0View file
@@ -53,6 +53,15 @@ holds it pre-exported in a few formats. An on-demand "estimate export sizes"
5353 action serializes the loaded mesh to every format in memory and shows the
5454 resulting file sizes in the format table and export dropdown.
5555
56+A loaded mesh can be shared as a self-contained link: "Copy share link" packs
57+the original uploaded file (byte-identical, in its original format) together
58+with the chosen export format and view mode into the URL fragment —
59+deflate-compressed and base64url-encoded after `#share=`. Nothing is uploaded;
60+the data lives in the URL itself, so whoever opens the link sees the mesh and
61+can download it in any format. Links are capped at 65,000 characters (roughly
62+a 100–300 KB mesh file, depending on how well it compresses); beyond that the
63+app says the mesh is too large to share by URL.
64+
5665 ## Development
5766
5867 ```bash
src/App.cssmodified+14−0View file
@@ -231,6 +231,20 @@ button.subtle:disabled {
231231 cursor: default;
232232 }
233233
234+input.share-url {
235+ font: inherit;
236+ font-size: 12px;
237+ display: block;
238+ width: 100%;
239+ box-sizing: border-box;
240+ margin-top: 8px;
241+ padding: 5px 8px;
242+ border-radius: 6px;
243+ border: 1px solid #3a4150;
244+ background: #2a2f39;
245+ color: #e2e5ea;
246+}
247+
234248 .footnote {
235249 margin: 8px 0 0;
236250 color: #6b7280;
src/App.tsxmodified+153−1View file
@@ -1,5 +1,7 @@
11 import { useEffect, useRef, useState } from 'react'
22 import { MeshView } from './MeshView'
3+import { VIEW_MODES } from './viewModes'
4+import type { ViewMode } from './viewModes'
35 import type { MeshData } from './mesh/types'
46 import { faceCount, vertexCount } from './mesh/types'
57 import { acceptedExtensions, conversionLosses, formatForFilename, formats } from './mesh/formats'
@@ -11,10 +13,26 @@ import {
1113 serializeMesh,
1214 } from './mesh/meshio'
1315 import { makeSampleMesh } from './mesh/sample'
16+import { buildShareUrl, MAX_SHARE_URL_CHARS, parseShareHash } from './share'
1417 import './App.css'
1518
1619 type EngineState = 'loading' | 'ready' | 'error'
1720
21+/**
22+ * What a share link would carry: the original uploaded file (so the recipient
23+ * gets byte-identical data in the original format), or a marker for the
24+ * generated sample mesh.
25+ */
26+type ShareSource =
27+ | { kind: 'file'; formatId: string; bytes: Uint8Array<ArrayBuffer> }
28+ | { kind: 'sample' }
29+
30+type ShareStatus =
31+ | { kind: 'copied'; chars: number }
32+ | { kind: 'manual'; url: string } // clipboard unavailable — show the link for hand-copying
33+ | { kind: 'too-large'; chars: number }
34+ | { kind: 'error'; message: string }
35+
1836 function formatBytes(n: number): string {
1937 if (n < 1024) return `${n} B`
2038 if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
@@ -34,7 +52,11 @@ function App() {
3452 })
3553 const [busy, setBusy] = useState<'parsing' | 'exporting' | 'sizing' | null>(null)
3654 const [exportSizes, setExportSizes] = useState<Record<string, number> | null>(null)
55+ const [viewMode, setViewMode] = useState<ViewMode>('both')
56+ const [shareSource, setShareSource] = useState<ShareSource | null>(null)
57+ const [shareStatus, setShareStatus] = useState<ShareStatus | null>(null)
3758 const fileInputRef = useRef<HTMLInputElement>(null)
59+ const shareLoadAttempted = useRef(false)
3860
3961 const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
4062 const losses = mesh ? conversionLosses(mesh, exportFormat) : []
@@ -62,6 +84,65 @@ function App() {
6284 }
6385 }, [])
6486
87+ // Load a mesh from a #share=… link on startup (parseMeshFile waits for the
88+ // engine init kicked off above).
89+ useEffect(() => {
90+ if (shareLoadAttempted.current) return
91+ shareLoadAttempted.current = true
92+ ;(async () => {
93+ let payload
94+ try {
95+ payload = await parseShareHash(window.location.hash)
96+ } catch (e) {
97+ setError(`Could not read the share link: ${e instanceof Error ? e.message : String(e)}`)
98+ return
99+ }
100+ if (!payload) return
101+ if (formats.some((f) => f.id === payload.exportFormatId)) {
102+ setExportFormatId(payload.exportFormatId)
103+ }
104+ if (VIEW_MODES.some((m) => m.id === payload.viewMode)) {
105+ setViewMode(payload.viewMode as ViewMode)
106+ }
107+ if (payload.formatId === null) {
108+ setMesh(makeSampleMesh())
109+ setShareSource({ kind: 'sample' })
110+ setParseWarnings([])
111+ setSourceLabel('built-in sample (from share link)')
112+ setBaseName(payload.name || 'rainbow_torus')
113+ return
114+ }
115+ const format = formats.find((f) => f.id === payload.formatId)
116+ if (!format) {
117+ setError(`Could not read the share link: unknown mesh format "${payload.formatId}"`)
118+ return
119+ }
120+ setBusy('parsing')
121+ try {
122+ const { mesh: parsed, info } = await parseMeshFile(payload.bytes, format)
123+ setMesh(parsed)
124+ setShareSource({ kind: 'file', formatId: format.id, bytes: payload.bytes })
125+ setParseWarnings(info.warnings)
126+ setSourceLabel(`${payload.name}${format.extension} (${format.label}, from share link)`)
127+ setBaseName(payload.name || 'mesh')
128+ } catch (e) {
129+ setError(
130+ `Could not load the shared mesh: ${e instanceof Error ? e.message : String(e)}`,
131+ )
132+ } finally {
133+ setBusy(null)
134+ }
135+ })()
136+ }, [])
137+
138+ // A share link only describes the mesh it was created for — drop it from
139+ // the address bar once a different mesh is loaded.
140+ const clearShareHash = () => {
141+ if (window.location.hash) {
142+ history.replaceState(null, '', window.location.pathname + window.location.search)
143+ }
144+ }
145+
65146 const handleFile = async (file: File) => {
66147 setError(null)
67148 const format = formatForFilename(file.name)
@@ -80,6 +161,9 @@ function App() {
80161 setParseWarnings(info.warnings)
81162 setSourceLabel(`${file.name} (${format.label})`)
82163 setBaseName(file.name.replace(/\.[^.]+$/, ''))
164+ setShareSource({ kind: 'file', formatId: format.id, bytes })
165+ setShareStatus(null)
166+ clearShareHash()
83167 } catch (e) {
84168 setError(e instanceof Error ? e.message : String(e))
85169 } finally {
@@ -94,6 +178,35 @@ function App() {
94178 setParseWarnings([])
95179 setSourceLabel('built-in sample')
96180 setBaseName('rainbow_torus')
181+ setShareSource({ kind: 'sample' })
182+ setShareStatus(null)
183+ clearShareHash()
184+ }
185+
186+ const shareMesh = async () => {
187+ if (!shareSource) return
188+ setShareStatus(null)
189+ try {
190+ const url = await buildShareUrl({
191+ name: baseName,
192+ formatId: shareSource.kind === 'file' ? shareSource.formatId : null,
193+ exportFormatId,
194+ viewMode,
195+ bytes: shareSource.kind === 'file' ? shareSource.bytes : new Uint8Array(0),
196+ })
197+ if (url.length > MAX_SHARE_URL_CHARS) {
198+ setShareStatus({ kind: 'too-large', chars: url.length })
199+ return
200+ }
201+ try {
202+ await navigator.clipboard.writeText(url)
203+ setShareStatus({ kind: 'copied', chars: url.length })
204+ } catch {
205+ setShareStatus({ kind: 'manual', url })
206+ }
207+ } catch (e) {
208+ setShareStatus({ kind: 'error', message: e instanceof Error ? e.message : String(e) })
209+ }
97210 }
98211
99212 const estimateSizes = async () => {
@@ -227,6 +340,45 @@ function App() {
227340 </section>
228341 )}
229342
343+ {mesh && shareSource && (
344+ <section>
345+ <h2>Share</h2>
346+ <button onClick={shareMesh} disabled={busy !== null}>
347+ Copy share link
348+ </button>
349+ {shareStatus?.kind === 'copied' && (
350+ <div className="ok">
351+ Link copied to clipboard ({shareStatus.chars.toLocaleString()} characters).
352+ </div>
353+ )}
354+ {shareStatus?.kind === 'manual' && (
355+ <div className="warning">
356+ Couldn’t write to the clipboard — copy the link below by hand.
357+ <input
358+ className="share-url"
359+ readOnly
360+ value={shareStatus.url}
361+ onFocus={(e) => e.target.select()}
362+ />
363+ </div>
364+ )}
365+ {shareStatus?.kind === 'too-large' && (
366+ <div className="warning">
367+ This mesh is too large to share by URL: the link would be{' '}
368+ {shareStatus.chars.toLocaleString()} characters, beyond the{' '}
369+ {MAX_SHARE_URL_CHARS.toLocaleString()} that links can reliably carry. Download
370+ the file and share it directly instead.
371+ </div>
372+ )}
373+ {shareStatus?.kind === 'error' && <div className="error">{shareStatus.message}</div>}
374+ <p className="footnote">
375+ The link embeds the compressed mesh (the original file) plus the export and view
376+ settings in the URL itself — nothing is uploaded anywhere. Whoever opens it can
377+ view the mesh and download it in any format.
378+ </p>
379+ </section>
380+ )}
381+
230382 <section>
231383 <h2>Formats</h2>
232384 <table className="format-table">
@@ -279,7 +431,7 @@ function App() {
279431
280432 <div className="viewport">
281433 {mesh ? (
282- <MeshView mesh={mesh} />
434+ <MeshView mesh={mesh} mode={viewMode} onModeChange={setViewMode} />
283435 ) : (
284436 <div className="empty-state">
285437 <p>No mesh loaded.</p>
src/MeshView.tsxmodified+13−14View file
@@ -1,17 +1,10 @@
1-import { useEffect, useMemo, useState } from 'react'
1+import { useEffect, useMemo } from 'react'
22 import { Canvas } from '@react-three/fiber'
33 import { OrbitControls } from '@react-three/drei'
44 import * as THREE from 'three'
55 import type { MeshData } from './mesh/types'
6-
7-type ViewMode = 'shaded' | 'wire' | 'both' | 'points'
8-
9-const VIEW_MODES: { id: ViewMode; label: string }[] = [
10- { id: 'shaded', label: 'Shaded' },
11- { id: 'wire', label: 'Wire' },
12- { id: 'both', label: 'Both' },
13- { id: 'points', label: 'Points' },
14-]
6+import { VIEW_MODES } from './viewModes'
7+import type { ViewMode } from './viewModes'
158
169 const PLAIN_COLOR = '#8fb4d9'
1710
@@ -87,9 +80,15 @@ function MeshObject({ mesh, mode }: { mesh: MeshData; mode: ViewMode }) {
8780 )
8881 }
8982
90-export function MeshView({ mesh }: { mesh: MeshData }) {
91- const [mode, setMode] = useState<ViewMode>('both')
92-
83+export function MeshView({
84+ mesh,
85+ mode,
86+ onModeChange,
87+}: {
88+ mesh: MeshData
89+ mode: ViewMode
90+ onModeChange: (mode: ViewMode) => void
91+}) {
9392 return (
9493 <>
9594 <div className="view-toolbar">
@@ -97,7 +96,7 @@ export function MeshView({ mesh }: { mesh: MeshData }) {
9796 <button
9897 key={m.id}
9998 className={mode === m.id ? 'active' : ''}
100- onClick={() => setMode(m.id)}
99+ onClick={() => onModeChange(m.id)}
101100 >
102101 {m.label}
103102 </button>
src/share.tsadded+123−0View file
@@ -0,0 +1,123 @@
1+/**
2+ * Share links: the loaded mesh — as the original file bytes in its original
3+ * format — plus viewer state, packed into the URL fragment. Container layout
4+ * before compression is [u32 LE header length][JSON header][file bytes];
5+ * that is deflate-raw compressed and base64url-encoded after "#share=".
6+ * The fragment stays in the browser — nothing is uploaded anywhere.
7+ *
8+ * This module is deliberately dependency-free (no DOM imports beyond what
9+ * Node also provides) so the codec can be exercised outside the browser.
10+ */
11+
12+export interface SharePayload {
13+ /** Base filename without extension */
14+ name: string
15+ /** meshio format id of `bytes`, or null for the built-in sample mesh */
16+ formatId: string | null
17+ exportFormatId: string
18+ viewMode: string
19+ /** Original file bytes (empty when formatId is null) */
20+ bytes: Uint8Array<ArrayBuffer>
21+}
22+
23+const HASH_PREFIX = '#share='
24+
25+/**
26+ * Longest URL we are willing to produce. Browsers themselves accept far
27+ * longer, but links beyond roughly 64k characters commonly get truncated or
28+ * de-linkified by messengers, email clients, and older tooling.
29+ */
30+export const MAX_SHARE_URL_CHARS = 65000
31+
32+interface ShareHeader {
33+ v: number
34+ name: string
35+ format: string | null
36+ export: string
37+ view: string
38+}
39+
40+async function pipeThrough(
41+ bytes: Uint8Array<ArrayBuffer>,
42+ transform: CompressionStream | DecompressionStream,
43+): Promise<Uint8Array<ArrayBuffer>> {
44+ const stream = new Blob([bytes]).stream().pipeThrough(transform)
45+ return new Uint8Array(await new Response(stream).arrayBuffer())
46+}
47+
48+function toBase64Url(bytes: Uint8Array): string {
49+ let binary = ''
50+ const chunk = 0x8000 // keep String.fromCharCode argument counts sane
51+ for (let i = 0; i < bytes.length; i += chunk) {
52+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
53+ }
54+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
55+}
56+
57+function fromBase64Url(encoded: string): Uint8Array<ArrayBuffer> {
58+ const b64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
59+ const padded = b64 + '='.repeat((4 - (b64.length % 4)) % 4)
60+ const binary = atob(padded)
61+ const bytes = new Uint8Array(binary.length)
62+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
63+ return bytes
64+}
65+
66+export async function encodeShare(payload: SharePayload): Promise<string> {
67+ const header: ShareHeader = {
68+ v: 1,
69+ name: payload.name,
70+ format: payload.formatId,
71+ export: payload.exportFormatId,
72+ view: payload.viewMode,
73+ }
74+ const headerBytes = new TextEncoder().encode(JSON.stringify(header))
75+ const container = new Uint8Array(4 + headerBytes.length + payload.bytes.length)
76+ new DataView(container.buffer).setUint32(0, headerBytes.length, true)
77+ container.set(headerBytes, 4)
78+ container.set(payload.bytes, 4 + headerBytes.length)
79+ const compressed = await pipeThrough(container, new CompressionStream('deflate-raw'))
80+ return toBase64Url(compressed)
81+}
82+
83+export async function decodeShare(encoded: string): Promise<SharePayload> {
84+ let container: Uint8Array<ArrayBuffer>
85+ try {
86+ container = await pipeThrough(fromBase64Url(encoded), new DecompressionStream('deflate-raw'))
87+ } catch {
88+ throw new Error('the share data in the URL is damaged or truncated')
89+ }
90+ if (container.length < 4) throw new Error('the share data in the URL is truncated')
91+ const headerLength = new DataView(container.buffer, container.byteOffset, 4).getUint32(0, true)
92+ if (4 + headerLength > container.length) {
93+ throw new Error('the share data in the URL is truncated')
94+ }
95+ let header: ShareHeader
96+ try {
97+ header = JSON.parse(new TextDecoder().decode(container.subarray(4, 4 + headerLength)))
98+ } catch {
99+ throw new Error('the share data in the URL is not valid')
100+ }
101+ if (header.v !== 1 || typeof header.name !== 'string' || typeof header.export !== 'string') {
102+ throw new Error('this share link was made by an incompatible version of the app')
103+ }
104+ return {
105+ name: header.name,
106+ formatId: header.format ?? null,
107+ exportFormatId: header.export,
108+ viewMode: header.view,
109+ bytes: container.slice(4 + headerLength),
110+ }
111+}
112+
113+/** Full shareable URL for the current page, e.g. "https://…/index.html#share=…". */
114+export async function buildShareUrl(payload: SharePayload): Promise<string> {
115+ const base = window.location.href.split('#')[0]
116+ return base + HASH_PREFIX + (await encodeShare(payload))
117+}
118+
119+/** Decode a location.hash; null if it is not a share link. */
120+export async function parseShareHash(hash: string): Promise<SharePayload | null> {
121+ if (!hash.startsWith(HASH_PREFIX)) return null
122+ return decodeShare(hash.slice(HASH_PREFIX.length))
123+}
src/viewModes.tsadded+8−0View file
@@ -0,0 +1,8 @@
1+export type ViewMode = 'shaded' | 'wire' | 'both' | 'points'
2+
3+export const VIEW_MODES: { id: ViewMode; label: string }[] = [
4+ { id: 'shaded', label: 'Shaded' },
5+ { id: 'wire', label: 'Wire' },
6+ { id: 'both', label: 'Both' },
7+ { id: 'points', label: 'Points' },
8+]