1/**
2 * JS side of the meshio bridge. Loads Pyodide (from the script tag in
3 * index.html), installs meshio via micropip, and exchanges mesh arrays with
4 * bridge.py through Pyodide's in-memory filesystem — everything runs in the
5 * browser, no server involved.
6 */
7import bridgeCode from './bridge.py?raw'
8import type { MeshData } from './types'
9import type { MeshFormat } from './formats'
11const PYODIDE_PACKAGES = ['micropip']
12const MESHIO_SPEC = 'meshio==5.3.5'
14const POSITIONS_F32 = '/work/positions.f32'
15const INDICES_U32 = '/work/indices.u32'
16const NORMALS_F32 = '/work/normals.f32'
17const COLORS_F32 = '/work/colors.f32'
19interface Pyodide {
20 runPython(code: string): unknown
21 loadPackage(names: string[]): Promise<unknown>
22 pyimport(name: string): { install(spec: string): Promise<void> }
23 FS: {
24 writeFile(path: string, data: Uint8Array): void
25 readFile(path: string): Uint8Array<ArrayBuffer>
26 unlink(path: string): void
27 mkdirTree(path: string): void
28 }
29}
31declare global {
32 // provided by the pyodide.js script tag in index.html
33 function loadPyodide(options?: { indexURL?: string }): Promise<Pyodide>
34}
36export interface ParseInfo {
37 numVertices: number
38 numFaces: number
39 hasNormals: boolean
40 hasColors: boolean
41 warnings: string[]
42}
44let initPromise: Promise<Pyodide> | null = null
46async function doInit(onProgress: (message: string) => void): Promise<Pyodide> {
47 if (typeof loadPyodide !== 'function') {
48 throw new Error('Pyodide script failed to load (offline? blocked CDN?)')
49 }
50 onProgress('Loading Python runtime (Pyodide)…')
51 const pyodide = await loadPyodide()
52 onProgress('Installing meshio…')
53 await pyodide.loadPackage(PYODIDE_PACKAGES)
54 await pyodide.pyimport('micropip').install(MESHIO_SPEC)
55 pyodide.runPython(bridgeCode)
56 return pyodide
57}
59/**
60 * Kick off (or join) the one-time Pyodide + meshio setup. Safe to call
61 * repeatedly; only the first caller's onProgress is used.
62 */
63export function initMeshio(onProgress: (message: string) => void = () => {}): Promise<Pyodide> {
64 if (!initPromise) initPromise = doInit(onProgress)
65 return initPromise
66}
68export async function getMeshioVersion(): Promise<string> {
69 const pyodide = await initMeshio()
70 return String(pyodide.runPython('meshio.__version__'))
71}
73const loadedPackages = new Set<string>()
75/**
76 * Load a format's extra Pyodide packages (e.g. h5py) on first use, so the
77 * default startup stays at just meshio + numpy.
78 */
79async function ensurePackages(pyodide: Pyodide, format: MeshFormat): Promise<void> {
80 const needed = (format.pyodidePackages ?? []).filter((p) => !loadedPackages.has(p))
81 if (needed.length === 0) return
82 await pyodide.loadPackage(needed)
83 needed.forEach((p) => loadedPackages.add(p))
84}
86/** Last line of a Python traceback, without the exception class name. */
87function pythonErrorMessage(err: unknown): string {
88 const raw = err instanceof Error ? err.message : String(err)
89 const lines = raw
90 .trim()
91 .split('\n')
92 .filter((l) => l.trim())
93 const last = lines[lines.length - 1] ?? raw
94 return last.replace(/^[\w.]+(?:Error|Exception|Exit)\s*:\s*/, '')
95}
97function runBridge(pyodide: Pyodide, code: string): string {
98 try {
99 return String(pyodide.runPython(code))
100 } catch (err) {
101 throw new Error(pythonErrorMessage(err))
102 }
103}
105function readF32(pyodide: Pyodide, path: string): Float32Array {
106 const bytes = pyodide.FS.readFile(path)
107 return new Float32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4)
108}
110function readU32(pyodide: Pyodide, path: string): Uint32Array {
111 const bytes = pyodide.FS.readFile(path)
112 return new Uint32Array(bytes.buffer, bytes.byteOffset, bytes.byteLength / 4)
113}
115function asBytes(array: Float32Array | Uint32Array): Uint8Array {
116 return new Uint8Array(array.buffer, array.byteOffset, array.byteLength)
117}
119export async function parseMeshFile(
120 bytes: Uint8Array,
121 format: MeshFormat,
122): Promise<{ mesh: MeshData; info: ParseInfo }> {
123 const pyodide = await initMeshio()
124 await ensurePackages(pyodide, format)
125 const inputPath = '/work/input' + format.extension
126 pyodide.FS.mkdirTree('/work')
127 pyodide.FS.writeFile(inputPath, bytes)
128 const info: ParseInfo = JSON.parse(
129 runBridge(
130 pyodide,
131 `parse_mesh_file(${JSON.stringify(inputPath)}, ${JSON.stringify(format.id)})`,
132 ),
133 )
134 const mesh: MeshData = {
135 positions: readF32(pyodide, POSITIONS_F32),
136 indices: readU32(pyodide, INDICES_U32),
137 normals: info.hasNormals ? readF32(pyodide, NORMALS_F32) : null,
138 colors: info.hasColors ? readF32(pyodide, COLORS_F32) : null,
139 }
140 pyodide.FS.unlink(inputPath)
141 return { mesh, info }
142}
144/**
145 * Native -> native conversion: write the original file bytes into /work and
146 * run convert_mesh, returning [pyodide, outPath, byteLength]. Both formats'
147 * extra packages are ensured — the source format's are needed to read, the
148 * target's to write.
149 */
150async function runConvert(
151 bytes: Uint8Array,
152 srcFormat: MeshFormat,
153 dstFormat: MeshFormat,
154): Promise<[Pyodide, string, number]> {
155 const pyodide = await initMeshio()
156 await ensurePackages(pyodide, srcFormat)
157 await ensurePackages(pyodide, dstFormat)
158 pyodide.FS.mkdirTree('/work')
159 const inPath = '/work/convert_in' + srcFormat.extension
160 const outPath = '/work/convert_out' + dstFormat.extension
161 pyodide.FS.writeFile(inPath, bytes)
162 const result = runBridge(
163 pyodide,
164 `convert_mesh(${JSON.stringify(inPath)}, ${JSON.stringify(srcFormat.id)}, ` +
165 `${JSON.stringify(outPath)}, ${JSON.stringify(dstFormat.id)})`,
166 )
167 pyodide.FS.unlink(inPath)
168 const { byteLength } = JSON.parse(result) as { byteLength: number }
169 return [pyodide, outPath, byteLength]
170}
172/**
173 * Convert the original file bytes from `srcFormat` to `dstFormat` through
174 * meshio directly (no detour through the viewer's common representation), so
175 * only what `dstFormat` cannot express is lost. Callers should short-circuit
176 * the same-format case and hand back the original bytes untouched.
177 */
178export async function convertMesh(
179 bytes: Uint8Array,
180 srcFormat: MeshFormat,
181 dstFormat: MeshFormat,
182): Promise<Uint8Array<ArrayBuffer>> {
183 const [pyodide, outPath] = await runConvert(bytes, srcFormat, dstFormat)
184 const out = pyodide.FS.readFile(outPath)
185 pyodide.FS.unlink(outPath)
186 return out
187}
189/** Byte size `bytes` would have converted to `dstFormat`, without keeping the bytes. */
190export async function estimateConvertSize(
191 bytes: Uint8Array,
192 srcFormat: MeshFormat,
193 dstFormat: MeshFormat,
194): Promise<number> {
195 const [pyodide, outPath, byteLength] = await runConvert(bytes, srcFormat, dstFormat)
196 pyodide.FS.unlink(outPath)
197 return byteLength
198}
200/** Write the mesh arrays into /work and run serialize_mesh; returns [pyodide, outPath, byteLength]. */
201async function runSerialize(
202 mesh: MeshData,
203 format: MeshFormat,
204): Promise<[Pyodide, string, number]> {
205 const pyodide = await initMeshio()
206 await ensurePackages(pyodide, format)
207 pyodide.FS.mkdirTree('/work')
208 pyodide.FS.writeFile(POSITIONS_F32, asBytes(mesh.positions))
209 pyodide.FS.writeFile(INDICES_U32, asBytes(mesh.indices))
210 const includeNormals = !!mesh.normals && format.capabilities.normals
211 const includeColors = !!mesh.colors && format.capabilities.colors
212 if (includeNormals) pyodide.FS.writeFile(NORMALS_F32, asBytes(mesh.normals!))
213 if (includeColors) pyodide.FS.writeFile(COLORS_F32, asBytes(mesh.colors!))
215 const outPath = '/work/out' + format.extension
216 const result = runBridge(
217 pyodide,
218 `serialize_mesh(${JSON.stringify(outPath)}, ${JSON.stringify(format.id)}, ` +
219 `${includeNormals ? 'True' : 'False'}, ${includeColors ? 'True' : 'False'})`,
220 )
221 const { byteLength } = JSON.parse(result) as { byteLength: number }
222 return [pyodide, outPath, byteLength]
223}
225/**
226 * Serialize the common-form `MeshData` to `format`. Used for the generated
227 * sample mesh, which has no original file; uploaded files export losslessly
228 * through {@link convertMesh} from their original bytes instead.
229 */
230export async function serializeMesh(
231 mesh: MeshData,
232 format: MeshFormat,
233): Promise<Uint8Array<ArrayBuffer>> {
234 const [pyodide, outPath] = await runSerialize(mesh, format)
235 const out = pyodide.FS.readFile(outPath)
236 pyodide.FS.unlink(outPath)
237 return out
238}
240/** Byte size the mesh would have in `format`, without keeping the bytes. */
241export async function estimateExportSize(mesh: MeshData, format: MeshFormat): Promise<number> {
242 const [pyodide, outPath, byteLength] = await runSerialize(mesh, format)
243 pyodide.FS.unlink(outPath)
244 return byteLength
245}