concept-collection / mesh-converter
Add spot sample mesh; tolerate OBJ files with more vt/vn than vertices
Jeremy Magland <jmagland@flatironinstitute.org> committed commit 3e0f39dfe3d5 parent c26fd7d Browse files
5 changed files+12098−11
README.mdmodified+5−3View file
@@ -63,9 +63,11 @@ cannot roundtrip their own output in meshio 5.3.5; exodus fails writing under
6363 wasm; flac3d holds volume cells only; wkt's reader hangs on non-toy meshes;
6464 tetgen spans two files; svg is write-only.
6565
66-A built-in sample mesh (a rainbow torus with normals and vertex colors) is
67-available from the UI for trying things out without a file, and `examples/`
68-holds it pre-exported in a few formats. An on-demand "estimate export sizes"
66+Two built-in sample meshes are available from the UI for trying things out
67+without a file: a generated rainbow torus with normals and vertex colors
68+(`examples/` holds it pre-exported in a few formats), and Keenan Crane's
69+public-domain [Spot](https://www.cs.cmu.edu/~kmcrane/Projects/ModelRepository/)
70+cow, bundled as a real OBJ file so it exercises the upload path. An on-demand "estimate export sizes"
6971 action serializes the loaded mesh to every format in memory and shows the
7072 resulting file sizes in the format table and export dropdown.
7173
src/App.cssmodified+14−0View file
@@ -57,11 +57,25 @@
5757 margin: 0 0 8px;
5858 }
5959
60+.load-buttons {
61+ display: flex;
62+ flex-direction: column;
63+ gap: 8px;
64+}
65+
66+.load-buttons > button {
67+ width: 100%;
68+}
69+
6070 .button-row {
6171 display: flex;
6272 gap: 8px;
6373 }
6474
75+.button-row button {
76+ flex: 1;
77+}
78+
6579 button {
6680 font: inherit;
6781 padding: 7px 12px;
src/App.tsxmodified+33−8View file
@@ -17,6 +17,7 @@ import {
1717 } from './mesh/meshio'
1818 import { makeSampleMesh } from './mesh/sample'
1919 import { buildShareUrl, MAX_SHARE_URL_CHARS, parseShareHash } from './share'
20+import spotUrl from './assets/spot_triangulated.obj?url'
2021 import './App.css'
2122
2223 type EngineState = 'loading' | 'ready' | 'error'
@@ -122,7 +123,7 @@ function App() {
122123 setMesh(makeSampleMesh())
123124 setSource({ kind: 'sample' })
124125 setParseWarnings([])
125- setSourceLabel('built-in sample (from share link)')
126+ setSourceLabel('rainbow torus (built-in, from share link)')
126127 setBaseName(payload.name || 'rainbow_torus')
127128 return
128129 }
@@ -185,18 +186,37 @@ function App() {
185186 }
186187 }
187188
188- const loadSample = () => {
189+ const loadTorus = () => {
189190 setError(null)
190191 setMesh(makeSampleMesh())
191192 setExportSizes(null)
192193 setParseWarnings([])
193- setSourceLabel('built-in sample')
194+ setSourceLabel('rainbow torus (built-in)')
194195 setBaseName('rainbow_torus')
195196 setSource({ kind: 'sample' })
196197 setShareStatus(null)
197198 clearShareHash()
198199 }
199200
201+ // Spot (Keenan Crane, public domain) ships with the app as a real OBJ file,
202+ // so it flows through the same path as an upload — original bytes kept as
203+ // the export/share source of truth.
204+ const loadSpot = async () => {
205+ setError(null)
206+ setBusy('parsing')
207+ let file: File
208+ try {
209+ const resp = await fetch(spotUrl)
210+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
211+ file = new File([await resp.arrayBuffer()], 'spot.obj')
212+ } catch (e) {
213+ setError(`Could not fetch the spot mesh: ${e instanceof Error ? e.message : String(e)}`)
214+ setBusy(null)
215+ return
216+ }
217+ await handleFile(file)
218+ }
219+
200220 const shareMesh = async () => {
201221 if (!source) return
202222 setShareStatus(null)
@@ -301,16 +321,21 @@ function App() {
301321
302322 <section>
303323 <h2>Load</h2>
304- <div className="button-row">
324+ <div className="load-buttons">
305325 <button
306326 onClick={() => fileInputRef.current?.click()}
307327 disabled={!engineReady || busy !== null}
308328 >
309329 {busy === 'parsing' ? 'Reading…' : 'Open mesh file…'}
310330 </button>
311- <button onClick={loadSample} disabled={busy !== null}>
312- Load sample
313- </button>
331+ <div className="button-row">
332+ <button onClick={loadTorus} disabled={busy !== null}>
333+ Load torus
334+ </button>
335+ <button onClick={loadSpot} disabled={!engineReady || busy !== null}>
336+ Load spot
337+ </button>
338+ </div>
314339 </div>
315340 <input
316341 ref={fileInputRef}
@@ -482,7 +507,7 @@ function App() {
482507 ) : (
483508 <div className="empty-state">
484509 <p>No mesh loaded.</p>
485- <p>Open a {acceptedExtensions.join(', ')} file — or load the sample.</p>
510+ <p>Open a {acceptedExtensions.join(', ')} file — or load a built-in sample.</p>
486511 </div>
487512 )}
488513 </div>
src/assets/spot_triangulated.objadded+12011−0View file
This diff is 12,016 lines long and is not shown.
src/mesh/bridge.pymodified+35−0View file
@@ -34,6 +34,35 @@ COLORS_F32 = WORK + "/colors.f32"
3434 os.makedirs(WORK, exist_ok=True)
3535
3636
37+class _ObjTolerantMesh(meshio.Mesh):
38+ """OBJ faces index texture coordinates and normals independently of
39+ vertex positions, so a file with UV seams legally has more vt (or vn)
40+ entries than v entries. meshio shoehorns those into point_data, whose
41+ per-vertex length check then rejects the whole file; drop the unmappable
42+ arrays instead and remember what was dropped so callers can warn."""
43+
44+ def __init__(self, points, cells, point_data=None, **kwargs):
45+ point_data = point_data or {}
46+ self.dropped_point_data = {
47+ key: len(value)
48+ for key, value in point_data.items()
49+ if len(value) != len(points)
50+ }
51+ point_data = {
52+ key: value
53+ for key, value in point_data.items()
54+ if key not in self.dropped_point_data
55+ }
56+ super().__init__(points, cells, point_data=point_data, **kwargs)
57+
58+
59+# the reader binds Mesh at module level, so this rebinding scopes the
60+# tolerance to OBJ reads only (elsewhere a mismatch means real corruption)
61+meshio.obj._obj.Mesh = _ObjTolerantMesh
62+
63+_OBJ_POINT_DATA_NAMES = {"obj:vt": "texture coordinates", "obj:vn": "vertex normals"}
64+
65+
3766 def _triangulate_cells(mesh, warnings):
3867 """Collect surface cells as triangles, fan-triangulating quads/polygons."""
3968 tri_blocks = []
@@ -152,6 +181,12 @@ def parse_mesh_file(path, file_format=None):
152181 except SystemExit:
153182 raise ValueError(f"Could not read file as {file_format or 'any known format'}")
154183
184+ for key, count in getattr(mesh, "dropped_point_data", {}).items():
185+ name = _OBJ_POINT_DATA_NAMES.get(key, key)
186+ warnings.append(
187+ f"dropped {name}: {count} entries for {len(mesh.points)} vertices"
188+ )
189+
155190 points = np.asarray(mesh.points, dtype=np.float32)
156191 if points.ndim != 2:
157192 raise ValueError(f"Unexpected points array shape {points.shape}")