/ concept-collection / mesh-converter
Sign in
concept-collection / mesh-converter
mesh-converter / src / mesh / meshio.ts
184 lines · 6.2 KBBlameHistoryRaw
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 }
31declare global {
32 // provided by the pyodide.js script tag in index.html
33 function loadPyodide(options?: { indexURL?: string }): Promise<Pyodide>
36export interface ParseInfo {
37 numVertices: number
38 numFaces: number
39 hasNormals: boolean
40 hasColors: boolean
41 warnings: string[]
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
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
68export async function getMeshioVersion(): Promise<string> {
69 const pyodide = await initMeshio()
70 return String(pyodide.runPython('meshio.__version__'))
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))
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*/, '')
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 }
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)
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)
115function asBytes(array: Float32Array | Uint32Array): Uint8Array {
116 return new Uint8Array(array.buffer, array.byteOffset, array.byteLength)
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 }
144/** Write the mesh arrays into /work and run serialize_mesh; returns [pyodide, outPath, byteLength]. */
145async function runSerialize(
146 mesh: MeshData,
147 format: MeshFormat,
148): Promise<[Pyodide, string, number]> {
149 const pyodide = await initMeshio()
150 await ensurePackages(pyodide, format)
151 pyodide.FS.mkdirTree('/work')
152 pyodide.FS.writeFile(POSITIONS_F32, asBytes(mesh.positions))
153 pyodide.FS.writeFile(INDICES_U32, asBytes(mesh.indices))
154 const includeNormals = !!mesh.normals && format.capabilities.normals
155 const includeColors = !!mesh.colors && format.capabilities.colors
156 if (includeNormals) pyodide.FS.writeFile(NORMALS_F32, asBytes(mesh.normals!))
157 if (includeColors) pyodide.FS.writeFile(COLORS_F32, asBytes(mesh.colors!))
159 const outPath = '/work/out' + format.extension
160 const result = runBridge(
161 pyodide,
162 `serialize_mesh(${JSON.stringify(outPath)}, ${JSON.stringify(format.id)}, ` +
163 `${includeNormals ? 'True' : 'False'}, ${includeColors ? 'True' : 'False'})`,
164 )
165 const { byteLength } = JSON.parse(result) as { byteLength: number }
166 return [pyodide, outPath, byteLength]
169export async function serializeMesh(
170 mesh: MeshData,
171 format: MeshFormat,
172): Promise<Uint8Array<ArrayBuffer>> {
173 const [pyodide, outPath] = await runSerialize(mesh, format)
174 const out = pyodide.FS.readFile(outPath)
175 pyodide.FS.unlink(outPath)
176 return out
179/** Byte size the mesh would have in `format`, without keeping the bytes. */
180export async function estimateExportSize(mesh: MeshData, format: MeshFormat): Promise<number> {
181 const [pyodide, outPath, byteLength] = await runSerialize(mesh, format)
182 pyodide.FS.unlink(outPath)
183 return byteLength
moveopenescclose