edd4e73Mesh converter: upload, view, and export meshes across formatsJeremy Magland 1import type { MeshData, MeshFormat } from '../types'
2import { parseNumbers, validateIndices } from '../types'
4/**
5 * MOPF — "Mesh Omni-Portable Format" (invented, for illustration).
6 * The full-fidelity format: positions, faces, normals, colors, mesh name.
7 *
8 * MOPF/1
9 * # comment
10 * name Rainbow Torus
11 * attributes position normal color
12 * counts <nVertices> <nFaces>
13 * v <x> <y> <z> [<nx> <ny> <nz>] [<r> <g> <b>]
14 * f <a> <b> <c>
15 */
16export const mopfFormat: MeshFormat = {
17 id: 'mopf',
18 label: 'MOPF (Mesh Omni-Portable Format)',
19 extension: '.mopf',
20 blurb: 'Full-fidelity: geometry, normals, colors, and mesh name.',
21 capabilities: { normals: true, colors: true, name: true },
23 parse(text: string): MeshData {
24 const lines = text
25 .split('\n')
26 .map((l) => l.trim())
27 .filter((l) => l.length > 0 && !l.startsWith('#'))
28 if (lines[0] !== 'MOPF/1') {
29 throw new Error('MOPF: file must start with "MOPF/1"')
30 }
32 let name: string | null = null
33 let attributes = ['position']
34 let counts: [number, number] | null = null
35 const positions: number[] = []
36 const normals: number[] = []
37 const colors: number[] = []
38 const indices: number[] = []
40 for (const line of lines.slice(1)) {
41 const tokens = line.split(/\s+/)
42 const keyword = tokens[0]
43 if (keyword === 'name') {
44 name = tokens.slice(1).join(' ')
45 } else if (keyword === 'attributes') {
46 attributes = tokens.slice(1)
47 if (attributes[0] !== 'position') {
48 throw new Error('MOPF: attributes must start with "position"')
49 }
50 } else if (keyword === 'counts') {
51 const [nv, nf] = parseNumbers(tokens.slice(1), 'MOPF counts')
52 counts = [nv, nf]
53 } else if (keyword === 'v') {
54 const expected = attributes.length * 3
55 const nums = parseNumbers(tokens.slice(1), 'MOPF vertex')
56 if (nums.length !== expected) {
57 throw new Error(`MOPF: vertex line has ${nums.length} numbers, expected ${expected}`)
58 }
59 let k = 0
60 positions.push(...nums.slice(k, (k += 3)))
61 if (attributes.includes('normal')) normals.push(...nums.slice(k, (k += 3)))
62 if (attributes.includes('color')) colors.push(...nums.slice(k, (k += 3)))
63 } else if (keyword === 'f') {
64 const nums = parseNumbers(tokens.slice(1), 'MOPF face')
65 if (nums.length !== 3) {
66 throw new Error('MOPF: face line must have exactly 3 indices')
67 }
68 indices.push(...nums)
69 } else {
70 throw new Error(`MOPF: unknown keyword "${keyword}"`)
71 }
72 }
74 const nVertices = positions.length / 3
75 if (counts && (counts[0] !== nVertices || counts[1] !== indices.length / 3)) {
76 throw new Error(
77 `MOPF: counts header says ${counts[0]} vertices / ${counts[1]} faces, ` +
78 `found ${nVertices} / ${indices.length / 3}`,
79 )
80 }
81 if (nVertices === 0) throw new Error('MOPF: no vertices found')
82 validateIndices(indices, nVertices, 'MOPF')
84 return {
85 name,
86 positions,
87 indices,
88 normals: normals.length > 0 ? normals : null,
89 colors: colors.length > 0 ? colors : null,
90 }
91 },
93 serialize(mesh: MeshData): string {
94 const attributes = ['position']
95 if (mesh.normals) attributes.push('normal')
96 if (mesh.colors) attributes.push('color')
97 const nVertices = mesh.positions.length / 3
98 const nFaces = mesh.indices.length / 3
100 const out: string[] = ['MOPF/1']
101 if (mesh.name) out.push(`name ${mesh.name}`)
102 out.push(`attributes ${attributes.join(' ')}`)
103 out.push(`counts ${nVertices} ${nFaces}`)
104 for (let i = 0; i < nVertices; i++) {
105 const parts = [fmt3(mesh.positions, i)]
106 if (mesh.normals) parts.push(fmt3(mesh.normals, i))
107 if (mesh.colors) parts.push(fmt3(mesh.colors, i))
108 out.push(`v ${parts.join(' ')}`)
109 }
110 for (let i = 0; i < nFaces; i++) {
111 out.push(`f ${mesh.indices[3 * i]} ${mesh.indices[3 * i + 1]} ${mesh.indices[3 * i + 2]}`)
112 }
113 return out.join('\n') + '\n'
114 },
115}
117function fmt3(arr: number[], i: number): string {
118 return `${round6(arr[3 * i])} ${round6(arr[3 * i + 1])} ${round6(arr[3 * i + 2])}`
119}
121function round6(x: number): number {
122 return Math.round(x * 1e6) / 1e6
123}