Mesh converter: upload, view, and export meshes across formats
Static Vite/React/TS app with an interactive three.js view. Ships with
three invented text formats (MOPF, TRICOL, BMSH) of differing fidelity
to exercise the lossy-conversion UI; real formats to come later.
27 changed files+21655−0
.github/workflows/deploy.ymladded+40−0View file
@@ -0,0 +1,40 @@
1+name: Deploy to GitHub Pages
2+
3+on:
4+ push:
5+ branches: [main]
6+ workflow_dispatch:
7+
8+permissions:
9+ contents: read
10+ pages: write
11+ id-token: write
12+
13+concurrency:
14+ group: pages
15+ cancel-in-progress: false
16+
17+jobs:
18+ build:
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - uses: actions/setup-node@v4
23+ with:
24+ node-version: 22
25+ cache: npm
26+ - run: npm ci
27+ - run: npm run build
28+ - uses: actions/upload-pages-artifact@v3
29+ with:
30+ path: dist
31+
32+ deploy:
33+ needs: build
34+ runs-on: ubuntu-latest
35+ environment:
36+ name: github-pages
37+ url: ${{ steps.deployment.outputs.page_url }}
38+ steps:
39+ - id: deployment
40+ uses: actions/deploy-pages@v4
.gitignoreadded+24−0View file
@@ -0,0 +1,24 @@
1+# Logs
2+logs
3+*.log
4+npm-debug.log*
5+yarn-debug.log*
6+yarn-error.log*
7+pnpm-debug.log*
8+lerna-debug.log*
9+
10+node_modules
11+dist
12+dist-ssr
13+*.local
14+
15+# Editor directories and files
16+.vscode/*
17+!.vscode/extensions.json
18+.idea
19+.DS_Store
20+*.suo
21+*.ntvs*
22+*.njsproj
23+*.sln
24+*.sw?
.oxlintrc.jsonadded+8−0View file
@@ -0,0 +1,8 @@
1+{
2+ "$schema": "./node_modules/oxlint/configuration_schema.json",
3+ "plugins": ["react", "typescript", "oxc"],
4+ "rules": {
5+ "react/rules-of-hooks": "error",
6+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
7+ }
8+}
README.mdadded+36−0View file
@@ -0,0 +1,36 @@
1+# mesh-converter
2+
3+A static web app for converting meshes between formats: upload a mesh file,
4+inspect it in an interactive 3D view, and download it in a format of your
5+choice. Formats differ in what they can store, and the UI shows exactly which
6+attributes (normals, colors, name) would be dropped by a lossy conversion.
7+
8+Currently ships with three **invented text formats** to illustrate the
9+workflow; real formats will replace them later:
10+
11+| Format | Extension | positions/faces | normals | colors | name |
12+| --- | --- | --- | --- | --- | --- |
13+| MOPF (Mesh Omni-Portable Format) | `.mopf` | ✓ | ✓ | ✓ | ✓ |
14+| TRICOL (TriColor Interchange) | `.tricol` | ✓ | — | ✓ | — |
15+| BMSH (BareMesh) | `.bmsh` | ✓ | — | — | — |
16+
17+A built-in sample mesh (a rainbow torus with normals and vertex colors) is
18+available from the UI for trying things out without a file.
19+
20+## Development
21+
22+```bash
23+npm install
24+npm run dev # local dev server
25+npm run build # static build in dist/
26+```
27+
28+Built with Vite, React, TypeScript, and three.js (react-three-fiber).
29+
30+## Adding a format
31+
32+Implement the `MeshFormat` interface in `src/mesh/formats/` (parse and
33+serialize against the internal `MeshData` representation, declaring which
34+optional attributes the format supports) and register it in
35+`src/mesh/formats/index.ts`. The upload, viewer, capability table, and
36+loss-warning UI pick it up automatically.
examples/rainbow_torus.bmshadded+6146−0View file
This diff is 6,151 lines long and is not shown.
examples/rainbow_torus.mopfadded+6148−0View file
This diff is 6,153 lines long and is not shown.
examples/rainbow_torus.tricoladded+6145−0View file
This diff is 6,150 lines long and is not shown.
index.htmladded+13−0View file
@@ -0,0 +1,13 @@
1+<!doctype html>
2+<html lang="en">
3+ <head>
4+ <meta charset="UTF-8" />
5+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+ <title>Mesh Converter</title>
8+ </head>
9+ <body>
10+ <div id="root"></div>
11+ <script type="module" src="/src/main.tsx"></script>
12+ </body>
13+</html>
package-lock.jsonadded+2111−0View file
This diff is 2,116 lines long and is not shown.
package.jsonadded+29−0View file
@@ -0,0 +1,29 @@
1+{
2+ "name": "mesh-converter",
3+ "private": true,
4+ "version": "0.0.0",
5+ "type": "module",
6+ "scripts": {
7+ "dev": "vite",
8+ "build": "tsc -b && vite build",
9+ "lint": "oxlint",
10+ "preview": "vite preview"
11+ },
12+ "dependencies": {
13+ "@react-three/drei": "^10.7.7",
14+ "@react-three/fiber": "^9.6.1",
15+ "react": "^19.2.7",
16+ "react-dom": "^19.2.7",
17+ "three": "^0.185.1"
18+ },
19+ "devDependencies": {
20+ "@types/node": "^24.13.2",
21+ "@types/react": "^19.2.17",
22+ "@types/react-dom": "^19.2.3",
23+ "@types/three": "^0.185.0",
24+ "@vitejs/plugin-react": "^6.0.3",
25+ "oxlint": "^1.71.0",
26+ "typescript": "~6.0.2",
27+ "vite": "^8.1.1"
28+ }
29+}
public/favicon.svgadded+1−0View file
@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-49.64" y="2.03" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-45.045" y="20.029" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-43.513" y="21.178" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="g" width="74.749" height="58.852" x="15.756" y="-17.901" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="h" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="i" width="61.377" height="25.362" x="23.548" y="2.284" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="j" width="56.045" height="63.649" x="-27.636" y="-22.853" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="k" width="54.814" height="64.646" x="20.116" y="-38.415" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="l" width="33.541" height="35.313" x="24.641" y="-11.323" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="m" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="n" width="54.814" height="64.646" x="-29.286" y="6.009" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="o" width="54.814" height="64.646" x="8.244" y="-2.416" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter><filter id="p" width="39.409" height="43.623" x="18.713" y="10.588" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="4.596"/></filter></defs></svg>
public/icons.svgadded+24−0View file
@@ -0,0 +1,24 @@
1+<svg xmlns="http://www.w3.org/2000/svg">
2+ <symbol id="bluesky-icon" viewBox="0 0 16 17">
3+ <g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
4+ <defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
5+ </symbol>
6+ <symbol id="discord-icon" viewBox="0 0 20 19">
7+ <path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
8+ </symbol>
9+ <symbol id="documentation-icon" viewBox="0 0 21 20">
10+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
11+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
12+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
13+ </symbol>
14+ <symbol id="github-icon" viewBox="0 0 19 19">
15+ <path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
16+ </symbol>
17+ <symbol id="social-icon" viewBox="0 0 20 20">
18+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
19+ <path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
20+ </symbol>
21+ <symbol id="x-icon" viewBox="0 0 19 19">
22+ <path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
23+ </symbol>
24+</svg>
src/App.cssadded+197−0View file
@@ -0,0 +1,197 @@
1+.app {
2+ display: flex;
3+ height: 100%;
4+}
5+
6+.sidebar {
7+ width: 340px;
8+ flex-shrink: 0;
9+ overflow-y: auto;
10+ padding: 20px;
11+ background: #1e2128;
12+ border-right: 1px solid #2c313a;
13+}
14+
15+.sidebar h1 {
16+ margin: 0 0 4px;
17+ font-size: 20px;
18+ color: #fff;
19+}
20+
21+.tagline {
22+ margin: 0 0 16px;
23+ color: #9aa1ac;
24+ font-size: 13px;
25+}
26+
27+.sidebar section {
28+ margin-bottom: 22px;
29+}
30+
31+.sidebar h2 {
32+ font-size: 12px;
33+ text-transform: uppercase;
34+ letter-spacing: 0.08em;
35+ color: #7f8794;
36+ margin: 0 0 8px;
37+}
38+
39+.button-row {
40+ display: flex;
41+ gap: 8px;
42+}
43+
44+button {
45+ font: inherit;
46+ padding: 7px 12px;
47+ border-radius: 6px;
48+ border: 1px solid #3a4150;
49+ background: #2a2f39;
50+ color: #e2e5ea;
51+ cursor: pointer;
52+}
53+
54+button:hover {
55+ background: #333947;
56+}
57+
58+button.primary {
59+ margin-top: 10px;
60+ width: 100%;
61+ background: #2f6fb3;
62+ border-color: #2f6fb3;
63+ color: #fff;
64+ font-weight: 600;
65+}
66+
67+button.primary:hover {
68+ background: #3a80c9;
69+}
70+
71+select {
72+ font: inherit;
73+ width: 100%;
74+ padding: 7px 8px;
75+ border-radius: 6px;
76+ border: 1px solid #3a4150;
77+ background: #2a2f39;
78+ color: #e2e5ea;
79+}
80+
81+.error {
82+ margin-top: 10px;
83+ padding: 8px 10px;
84+ border-radius: 6px;
85+ background: rgba(220, 70, 70, 0.12);
86+ border: 1px solid rgba(220, 70, 70, 0.45);
87+ color: #f2a3a3;
88+ font-size: 13px;
89+}
90+
91+.warning {
92+ margin-top: 10px;
93+ padding: 8px 10px;
94+ border-radius: 6px;
95+ background: rgba(230, 170, 50, 0.1);
96+ border: 1px solid rgba(230, 170, 50, 0.45);
97+ color: #ecc477;
98+ font-size: 13px;
99+}
100+
101+.ok {
102+ margin-top: 10px;
103+ padding: 8px 10px;
104+ border-radius: 6px;
105+ background: rgba(80, 190, 120, 0.1);
106+ border: 1px solid rgba(80, 190, 120, 0.4);
107+ color: #8fd7ab;
108+ font-size: 13px;
109+}
110+
111+.mesh-info .source {
112+ color: #7f8794;
113+ font-size: 12px;
114+ margin-bottom: 4px;
115+ overflow-wrap: anywhere;
116+}
117+
118+.chips {
119+ display: flex;
120+ flex-wrap: wrap;
121+ gap: 6px;
122+ margin-top: 8px;
123+}
124+
125+.chip {
126+ padding: 2px 8px;
127+ border-radius: 999px;
128+ font-size: 12px;
129+ border: 1px solid #3a4150;
130+ color: #6b7280;
131+ text-decoration: line-through;
132+}
133+
134+.chip.on {
135+ color: #a8d5b8;
136+ border-color: rgba(80, 190, 120, 0.5);
137+ text-decoration: none;
138+}
139+
140+.format-blurb {
141+ margin: 8px 0 0;
142+ color: #9aa1ac;
143+ font-size: 13px;
144+}
145+
146+.format-table {
147+ width: 100%;
148+ border-collapse: collapse;
149+ font-size: 13px;
150+}
151+
152+.format-table th,
153+.format-table td {
154+ text-align: left;
155+ padding: 4px 6px;
156+ border-bottom: 1px solid #2c313a;
157+}
158+
159+.format-table th {
160+ color: #7f8794;
161+ font-weight: 500;
162+}
163+
164+.format-table th:not(:first-child),
165+.format-table td:not(:first-child) {
166+ text-align: center;
167+}
168+
169+.ext {
170+ color: #7f8794;
171+ font-size: 12px;
172+}
173+
174+.footnote {
175+ margin: 8px 0 0;
176+ color: #6b7280;
177+ font-size: 12px;
178+}
179+
180+.viewport {
181+ flex: 1;
182+ min-width: 0;
183+ position: relative;
184+}
185+
186+.empty-state {
187+ height: 100%;
188+ display: flex;
189+ flex-direction: column;
190+ align-items: center;
191+ justify-content: center;
192+ color: #6b7280;
193+}
194+
195+.empty-state p {
196+ margin: 4px 0;
197+}
src/App.tsxadded+175−0View file
@@ -0,0 +1,175 @@
1+import { useRef, useState } from 'react'
2+import { MeshView } from './MeshView'
3+import type { MeshData } from './mesh/types'
4+import { faceCount, vertexCount } from './mesh/types'
5+import { acceptedExtensions, conversionLosses, formatForFilename, formats } from './mesh/formats'
6+import { makeSampleMesh } from './mesh/sample'
7+import './App.css'
8+
9+function App() {
10+ const [mesh, setMesh] = useState<MeshData | null>(null)
11+ const [sourceLabel, setSourceLabel] = useState<string>('')
12+ const [error, setError] = useState<string | null>(null)
13+ const [exportFormatId, setExportFormatId] = useState(formats[0].id)
14+ const fileInputRef = useRef<HTMLInputElement>(null)
15+
16+ const exportFormat = formats.find((f) => f.id === exportFormatId) ?? formats[0]
17+ const losses = mesh ? conversionLosses(mesh, exportFormat) : []
18+
19+ const handleFile = async (file: File) => {
20+ setError(null)
21+ const format = formatForFilename(file.name)
22+ if (!format) {
23+ setError(
24+ `Unrecognized extension on "${file.name}". Supported: ${acceptedExtensions.join(', ')}`,
25+ )
26+ return
27+ }
28+ try {
29+ const text = await file.text()
30+ setMesh(format.parse(text))
31+ setSourceLabel(`${file.name} (${format.label})`)
32+ } catch (e) {
33+ setError(e instanceof Error ? e.message : String(e))
34+ }
35+ }
36+
37+ const loadSample = () => {
38+ setError(null)
39+ setMesh(makeSampleMesh())
40+ setSourceLabel('built-in sample')
41+ }
42+
43+ const downloadExport = () => {
44+ if (!mesh) return
45+ const text = exportFormat.serialize(mesh)
46+ const base = (mesh.name ?? 'mesh').replace(/[^\w-]+/g, '_').toLowerCase() || 'mesh'
47+ const blob = new Blob([text], { type: 'text/plain' })
48+ const url = URL.createObjectURL(blob)
49+ const a = document.createElement('a')
50+ a.href = url
51+ a.download = base + exportFormat.extension
52+ a.click()
53+ URL.revokeObjectURL(url)
54+ }
55+
56+ return (
57+ <div className="app">
58+ <div className="sidebar">
59+ <h1>Mesh Converter</h1>
60+ <p className="tagline">
61+ Load a mesh, inspect it in 3D, export to another format. Formats differ in what they can
62+ store — anything the target can't hold is dropped.
63+ </p>
64+
65+ <section>
66+ <h2>Load</h2>
67+ <div className="button-row">
68+ <button onClick={() => fileInputRef.current?.click()}>Open mesh file…</button>
69+ <button onClick={loadSample}>Load sample</button>
70+ </div>
71+ <input
72+ ref={fileInputRef}
73+ type="file"
74+ accept={acceptedExtensions.join(',')}
75+ hidden
76+ onChange={(e) => {
77+ const file = e.target.files?.[0]
78+ if (file) handleFile(file)
79+ e.target.value = ''
80+ }}
81+ />
82+ {error && <div className="error">{error}</div>}
83+ </section>
84+
85+ {mesh && (
86+ <section>
87+ <h2>Loaded mesh</h2>
88+ <div className="mesh-info">
89+ <div className="source">{sourceLabel}</div>
90+ <div>
91+ <strong>{mesh.name ?? '(unnamed)'}</strong> — {vertexCount(mesh)} vertices,{' '}
92+ {faceCount(mesh)} faces
93+ </div>
94+ <div className="chips">
95+ <span className="chip on">positions</span>
96+ <span className="chip on">faces</span>
97+ <span className={`chip ${mesh.normals ? 'on' : ''}`}>normals</span>
98+ <span className={`chip ${mesh.colors ? 'on' : ''}`}>colors</span>
99+ <span className={`chip ${mesh.name ? 'on' : ''}`}>name</span>
100+ </div>
101+ </div>
102+ </section>
103+ )}
104+
105+ {mesh && (
106+ <section>
107+ <h2>Export</h2>
108+ <select value={exportFormatId} onChange={(e) => setExportFormatId(e.target.value)}>
109+ {formats.map((f) => (
110+ <option key={f.id} value={f.id}>
111+ {f.label} — {f.extension}
112+ </option>
113+ ))}
114+ </select>
115+ <p className="format-blurb">{exportFormat.blurb}</p>
116+ {losses.length > 0 ? (
117+ <div className="warning">
118+ Exporting to {exportFormat.extension} will drop:{' '}
119+ <strong>{losses.join(', ')}</strong>
120+ </div>
121+ ) : (
122+ <div className="ok">Lossless — this format keeps everything in the loaded mesh.</div>
123+ )}
124+ <button className="primary" onClick={downloadExport}>
125+ Download {exportFormat.extension}
126+ </button>
127+ </section>
128+ )}
129+
130+ <section>
131+ <h2>Formats</h2>
132+ <table className="format-table">
133+ <thead>
134+ <tr>
135+ <th>Format</th>
136+ <th>normals</th>
137+ <th>colors</th>
138+ <th>name</th>
139+ </tr>
140+ </thead>
141+ <tbody>
142+ {formats.map((f) => (
143+ <tr key={f.id}>
144+ <td>
145+ {f.id.toUpperCase()} <span className="ext">{f.extension}</span>
146+ </td>
147+ <td>{f.capabilities.normals ? '✓' : '—'}</td>
148+ <td>{f.capabilities.colors ? '✓' : '—'}</td>
149+ <td>{f.capabilities.name ? '✓' : '—'}</td>
150+ </tr>
151+ ))}
152+ </tbody>
153+ </table>
154+ <p className="footnote">
155+ These are invented text formats for illustration; real formats come later. All store
156+ positions and triangle faces.
157+ </p>
158+ </section>
159+ </div>
160+
161+ <div className="viewport">
162+ {mesh ? (
163+ <MeshView mesh={mesh} />
164+ ) : (
165+ <div className="empty-state">
166+ <p>No mesh loaded.</p>
167+ <p>Open a .mopf, .tricol, or .bmsh file — or load the sample.</p>
168+ </div>
169+ )}
170+ </div>
171+ </div>
172+ )
173+}
174+
175+export default App
src/MeshView.tsxadded+54−0View file
@@ -0,0 +1,54 @@
1+import { useEffect, useMemo } from 'react'
2+import { Canvas } from '@react-three/fiber'
3+import { OrbitControls } from '@react-three/drei'
4+import * as THREE from 'three'
5+import type { MeshData } from './mesh/types'
6+
7+function MeshObject({ mesh }: { mesh: MeshData }) {
8+ const geometry = useMemo(() => {
9+ const g = new THREE.BufferGeometry()
10+ g.setAttribute('position', new THREE.Float32BufferAttribute(mesh.positions, 3))
11+ g.setIndex(mesh.indices)
12+ if (mesh.normals) {
13+ g.setAttribute('normal', new THREE.Float32BufferAttribute(mesh.normals, 3))
14+ } else {
15+ g.computeVertexNormals()
16+ }
17+ if (mesh.colors) {
18+ g.setAttribute('color', new THREE.Float32BufferAttribute(mesh.colors, 3))
19+ }
20+ // Center and scale to a consistent size so the fixed camera always frames it
21+ g.center()
22+ g.computeBoundingSphere()
23+ return g
24+ }, [mesh])
25+
26+ useEffect(() => () => geometry.dispose(), [geometry])
27+
28+ const scale = 1.6 / (geometry.boundingSphere?.radius || 1)
29+
30+ return (
31+ <mesh geometry={geometry} scale={scale}>
32+ <meshStandardMaterial
33+ vertexColors={!!mesh.colors}
34+ color={mesh.colors ? 'white' : '#8fb4d9'}
35+ roughness={0.55}
36+ metalness={0.1}
37+ side={THREE.DoubleSide}
38+ />
39+ </mesh>
40+ )
41+}
42+
43+export function MeshView({ mesh }: { mesh: MeshData }) {
44+ return (
45+ <Canvas camera={{ position: [2.6, 1.8, 2.6], fov: 45 }}>
46+ <color attach="background" args={['#16181d']} />
47+ <ambientLight intensity={0.5} />
48+ <directionalLight position={[5, 8, 4]} intensity={1.6} />
49+ <directionalLight position={[-4, -3, -6]} intensity={0.4} />
50+ <MeshObject mesh={mesh} />
51+ <OrbitControls makeDefault />
52+ </Canvas>
53+ )
54+}
src/index.cssadded+18−0View file
@@ -0,0 +1,18 @@
1+* {
2+ box-sizing: border-box;
3+}
4+
5+html,
6+body,
7+#root {
8+ margin: 0;
9+ height: 100%;
10+}
11+
12+body {
13+ font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
14+ font-size: 14px;
15+ line-height: 1.5;
16+ color: #d6d9de;
17+ background: #16181d;
18+}
src/main.tsxadded+10−0View file
@@ -0,0 +1,10 @@
1+import { StrictMode } from 'react'
2+import { createRoot } from 'react-dom/client'
3+import './index.css'
4+import App from './App.tsx'
5+
6+createRoot(document.getElementById('root')!).render(
7+ <StrictMode>
8+ <App />
9+ </StrictMode>,
10+)
src/mesh/formats/bmsh.tsadded+60−0View file
@@ -0,0 +1,60 @@
1+import type { MeshData, MeshFormat } from '../types'
2+import { parseNumbers, validateIndices } from '../types'
3+
4+/**
5+ * BMSH — "BareMesh" (invented, for illustration).
6+ * The minimal format: raw geometry only. No normals, colors, or name.
7+ *
8+ * BMSH
9+ * <nVertices> <nFaces>
10+ * <x> <y> <z> (nVertices lines)
11+ * <a> <b> <c> (nFaces lines)
12+ */
13+export const bmshFormat: MeshFormat = {
14+ id: 'bmsh',
15+ label: 'BMSH (BareMesh)',
16+ extension: '.bmsh',
17+ blurb: 'Bare geometry only: positions and faces, nothing else.',
18+ capabilities: { normals: false, colors: false, name: false },
19+
20+ parse(text: string): MeshData {
21+ const tokens = text.split(/\s+/).filter((t) => t.length > 0)
22+ if (tokens[0] !== 'BMSH') {
23+ throw new Error('BMSH: file must start with "BMSH"')
24+ }
25+ const nums = parseNumbers(tokens.slice(1), 'BMSH')
26+ const nVertices = nums[0]
27+ const nFaces = nums[1]
28+ if (!Number.isInteger(nVertices) || !Number.isInteger(nFaces) || nVertices <= 0 || nFaces < 0) {
29+ throw new Error('BMSH: invalid vertex/face counts')
30+ }
31+ const expected = 2 + nVertices * 3 + nFaces * 3
32+ if (nums.length !== expected) {
33+ throw new Error(`BMSH: expected ${expected - 2} numbers after counts, got ${nums.length - 2}`)
34+ }
35+ const positions = nums.slice(2, 2 + nVertices * 3)
36+ const indices = nums.slice(2 + nVertices * 3)
37+ validateIndices(indices, nVertices, 'BMSH')
38+
39+ return { name: null, positions, indices, normals: null, colors: null }
40+ },
41+
42+ serialize(mesh: MeshData): string {
43+ const nVertices = mesh.positions.length / 3
44+ const nFaces = mesh.indices.length / 3
45+ const out: string[] = ['BMSH', `${nVertices} ${nFaces}`]
46+ for (let i = 0; i < nVertices; i++) {
47+ out.push(
48+ `${round6(mesh.positions[3 * i])} ${round6(mesh.positions[3 * i + 1])} ${round6(mesh.positions[3 * i + 2])}`,
49+ )
50+ }
51+ for (let i = 0; i < nFaces; i++) {
52+ out.push(`${mesh.indices[3 * i]} ${mesh.indices[3 * i + 1]} ${mesh.indices[3 * i + 2]}`)
53+ }
54+ return out.join('\n') + '\n'
55+ },
56+}
57+
58+function round6(x: number): number {
59+ return Math.round(x * 1e6) / 1e6
60+}
src/mesh/formats/index.tsadded+25−0View file
@@ -0,0 +1,25 @@
1+import type { MeshData, MeshFormat } from '../types'
2+import { mopfFormat } from './mopf'
3+import { tricolFormat } from './tricol'
4+import { bmshFormat } from './bmsh'
5+
6+export const formats: MeshFormat[] = [mopfFormat, tricolFormat, bmshFormat]
7+
8+export function formatForFilename(filename: string): MeshFormat | null {
9+ const lower = filename.toLowerCase()
10+ return formats.find((f) => lower.endsWith(f.extension)) ?? null
11+}
12+
13+export const acceptedExtensions = formats.map((f) => f.extension)
14+
15+/**
16+ * Human-readable list of attributes of `mesh` that `target` cannot store
17+ * (empty array means the conversion is lossless).
18+ */
19+export function conversionLosses(mesh: MeshData, target: MeshFormat): string[] {
20+ const losses: string[] = []
21+ if (mesh.normals && !target.capabilities.normals) losses.push('vertex normals')
22+ if (mesh.colors && !target.capabilities.colors) losses.push('vertex colors')
23+ if (mesh.name && !target.capabilities.name) losses.push('mesh name')
24+ return losses
25+}
src/mesh/formats/mopf.tsadded+123−0View file
@@ -0,0 +1,123 @@
1+import type { MeshData, MeshFormat } from '../types'
2+import { parseNumbers, validateIndices } from '../types'
3+
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+ */
16+export 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 },
22+
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+ }
31+
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[] = []
39+
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+ }
73+
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')
83+
84+ return {
85+ name,
86+ positions,
87+ indices,
88+ normals: normals.length > 0 ? normals : null,
89+ colors: colors.length > 0 ? colors : null,
90+ }
91+ },
92+
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
99+
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+}
116+
117+function fmt3(arr: number[], i: number): string {
118+ return `${round6(arr[3 * i])} ${round6(arr[3 * i + 1])} ${round6(arr[3 * i + 2])}`
119+}
120+
121+function round6(x: number): number {
122+ return Math.round(x * 1e6) / 1e6
123+}
src/mesh/formats/tricol.tsadded+93−0View file
@@ -0,0 +1,93 @@
1+import type { MeshData, MeshFormat } from '../types'
2+import { parseNumbers, validateIndices } from '../types'
3+
4+/**
5+ * TRICOL — "TriColor Interchange" (invented, for illustration).
6+ * Comma-separated records, one per line. Stores geometry and optional
7+ * per-vertex colors, but no normals and no mesh name.
8+ *
9+ * # comment
10+ * V,<x>,<y>,<z>[,<r>,<g>,<b>]
11+ * F,<a>,<b>,<c>
12+ */
13+export const tricolFormat: MeshFormat = {
14+ id: 'tricol',
15+ label: 'TRICOL (TriColor Interchange)',
16+ extension: '.tricol',
17+ blurb: 'Geometry plus vertex colors. No normals, no mesh name.',
18+ capabilities: { normals: false, colors: true, name: false },
19+
20+ parse(text: string): MeshData {
21+ const positions: number[] = []
22+ const colors: number[] = []
23+ const indices: number[] = []
24+ let sawColorless = false
25+
26+ for (const rawLine of text.split('\n')) {
27+ const line = rawLine.trim()
28+ if (line.length === 0 || line.startsWith('#')) continue
29+ const tokens = line.split(',').map((t) => t.trim())
30+ const kind = tokens[0]
31+ if (kind === 'V') {
32+ const nums = parseNumbers(tokens.slice(1), 'TRICOL vertex')
33+ if (nums.length === 3) {
34+ sawColorless = true
35+ } else if (nums.length === 6) {
36+ colors.push(nums[3], nums[4], nums[5])
37+ } else {
38+ throw new Error(`TRICOL: V record needs 3 or 6 numbers, got ${nums.length}`)
39+ }
40+ positions.push(nums[0], nums[1], nums[2])
41+ } else if (kind === 'F') {
42+ const nums = parseNumbers(tokens.slice(1), 'TRICOL face')
43+ if (nums.length !== 3) {
44+ throw new Error('TRICOL: F record must have exactly 3 indices')
45+ }
46+ indices.push(...nums)
47+ } else {
48+ throw new Error(`TRICOL: unknown record type "${kind}"`)
49+ }
50+ }
51+
52+ const nVertices = positions.length / 3
53+ if (nVertices === 0) throw new Error('TRICOL: no vertices found')
54+ if (colors.length > 0 && sawColorless) {
55+ throw new Error('TRICOL: either all V records have colors or none do')
56+ }
57+ validateIndices(indices, nVertices, 'TRICOL')
58+
59+ return {
60+ name: null,
61+ positions,
62+ indices,
63+ normals: null,
64+ colors: colors.length > 0 ? colors : null,
65+ }
66+ },
67+
68+ serialize(mesh: MeshData): string {
69+ const nVertices = mesh.positions.length / 3
70+ const nFaces = mesh.indices.length / 3
71+ const out: string[] = ['# TRICOL mesh']
72+ for (let i = 0; i < nVertices; i++) {
73+ const p = [mesh.positions[3 * i], mesh.positions[3 * i + 1], mesh.positions[3 * i + 2]]
74+ const fields = p.map(round6)
75+ if (mesh.colors) {
76+ fields.push(
77+ round6(mesh.colors[3 * i]),
78+ round6(mesh.colors[3 * i + 1]),
79+ round6(mesh.colors[3 * i + 2]),
80+ )
81+ }
82+ out.push(`V,${fields.join(',')}`)
83+ }
84+ for (let i = 0; i < nFaces; i++) {
85+ out.push(`F,${mesh.indices[3 * i]},${mesh.indices[3 * i + 1]},${mesh.indices[3 * i + 2]}`)
86+ }
87+ return out.join('\n') + '\n'
88+ },
89+}
90+
91+function round6(x: number): number {
92+ return Math.round(x * 1e6) / 1e6
93+}
src/mesh/sample.tsadded+50−0View file
@@ -0,0 +1,50 @@
1+import type { MeshData } from './types'
2+
3+/**
4+ * A torus with analytic normals and rainbow vertex colors — carries every
5+ * attribute the richest format supports, so lossy export is easy to see.
6+ */
7+export function makeSampleMesh(): MeshData {
8+ const R = 1.0
9+ const r = 0.4
10+ const nu = 64 // around the main ring
11+ const nv = 32 // around the tube
12+
13+ const positions: number[] = []
14+ const normals: number[] = []
15+ const colors: number[] = []
16+ const indices: number[] = []
17+
18+ for (let i = 0; i < nu; i++) {
19+ const u = (i / nu) * 2 * Math.PI
20+ for (let j = 0; j < nv; j++) {
21+ const v = (j / nv) * 2 * Math.PI
22+ positions.push(
23+ (R + r * Math.cos(v)) * Math.cos(u),
24+ r * Math.sin(v),
25+ (R + r * Math.cos(v)) * Math.sin(u),
26+ )
27+ normals.push(Math.cos(v) * Math.cos(u), Math.sin(v), Math.cos(v) * Math.sin(u))
28+ colors.push(...hslToRgb(i / nu, 0.8, 0.55))
29+ }
30+ }
31+ for (let i = 0; i < nu; i++) {
32+ for (let j = 0; j < nv; j++) {
33+ const a = i * nv + j
34+ const b = ((i + 1) % nu) * nv + j
35+ const c = i * nv + ((j + 1) % nv)
36+ const d = ((i + 1) % nu) * nv + ((j + 1) % nv)
37+ indices.push(a, b, d, a, d, c)
38+ }
39+ }
40+
41+ return { name: 'Rainbow Torus', positions, indices, normals, colors }
42+}
43+
44+function hslToRgb(h: number, s: number, l: number): [number, number, number] {
45+ const f = (n: number) => {
46+ const k = (n + h * 12) % 12
47+ return l - s * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1))
48+ }
49+ return [f(0), f(8), f(4)]
50+}
src/mesh/types.tsadded+61−0View file
@@ -0,0 +1,61 @@
1+/**
2+ * Internal mesh representation. Every format parses into this and
3+ * serializes out of it. Optional attributes are null when absent.
4+ */
5+export interface MeshData {
6+ /** Human-readable mesh name (not all formats can store one) */
7+ name: string | null
8+ /** Flat xyz triples, 3 numbers per vertex */
9+ positions: number[]
10+ /** Flat triangle indices (0-based), 3 numbers per face */
11+ indices: number[]
12+ /** Flat xyz triples, 3 numbers per vertex, or null */
13+ normals: number[] | null
14+ /** Flat rgb triples in [0,1], 3 numbers per vertex, or null */
15+ colors: number[] | null
16+}
17+
18+export interface MeshCapabilities {
19+ normals: boolean
20+ colors: boolean
21+ name: boolean
22+}
23+
24+export interface MeshFormat {
25+ id: string
26+ label: string
27+ /** File extension including the dot, e.g. ".mopf" */
28+ extension: string
29+ blurb: string
30+ capabilities: MeshCapabilities
31+ /** Parse file text; throws Error with a user-facing message on bad input */
32+ parse(text: string): MeshData
33+ /** Serialize, silently dropping attributes the format cannot hold */
34+ serialize(mesh: MeshData): string
35+}
36+
37+export function vertexCount(mesh: MeshData): number {
38+ return mesh.positions.length / 3
39+}
40+
41+export function faceCount(mesh: MeshData): number {
42+ return mesh.indices.length / 3
43+}
44+
45+export function parseNumbers(tokens: string[], context: string): number[] {
46+ return tokens.map((t) => {
47+ const x = Number(t)
48+ if (!Number.isFinite(x)) {
49+ throw new Error(`${context}: "${t}" is not a number`)
50+ }
51+ return x
52+ })
53+}
54+
55+export function validateIndices(indices: number[], nVertices: number, context: string): void {
56+ for (const i of indices) {
57+ if (!Number.isInteger(i) || i < 0 || i >= nVertices) {
58+ throw new Error(`${context}: face index ${i} out of range (0..${nVertices - 1})`)
59+ }
60+ }
61+}
tsconfig.app.jsonadded+26−0View file
@@ -0,0 +1,26 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023", "DOM"],
6+ "module": "esnext",
7+ "types": ["vite/client"],
8+ "allowArbitraryExtensions": true,
9+ "skipLibCheck": true,
10+
11+ /* Bundler mode */
12+ "moduleResolution": "bundler",
13+ "allowImportingTsExtensions": true,
14+ "verbatimModuleSyntax": true,
15+ "moduleDetection": "force",
16+ "noEmit": true,
17+ "jsx": "react-jsx",
18+
19+ /* Linting */
20+ "noUnusedLocals": true,
21+ "noUnusedParameters": true,
22+ "erasableSyntaxOnly": true,
23+ "noFallthroughCasesInSwitch": true
24+ },
25+ "include": ["src"]
26+}
tsconfig.jsonadded+7−0View file
@@ -0,0 +1,7 @@
1+{
2+ "files": [],
3+ "references": [
4+ { "path": "./tsconfig.app.json" },
5+ { "path": "./tsconfig.node.json" }
6+ ]
7+}
tsconfig.node.jsonadded+23−0View file
@@ -0,0 +1,23 @@
1+{
2+ "compilerOptions": {
3+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4+ "target": "es2023",
5+ "lib": ["ES2023"],
6+ "types": ["node"],
7+ "skipLibCheck": true,
8+
9+ /* Bundler mode */
10+ "module": "nodenext",
11+ "allowImportingTsExtensions": true,
12+ "verbatimModuleSyntax": true,
13+ "moduleDetection": "force",
14+ "noEmit": true,
15+
16+ /* Linting */
17+ "noUnusedLocals": true,
18+ "noUnusedParameters": true,
19+ "erasableSyntaxOnly": true,
20+ "noFallthroughCasesInSwitch": true
21+ },
22+ "include": ["vite.config.ts"]
23+}
vite.config.tsadded+8−0View file
@@ -0,0 +1,8 @@
1+import { defineConfig } from 'vite'
2+import react from '@vitejs/plugin-react'
3+
4+// https://vite.dev/config/
5+export default defineConfig({
6+ plugins: [react()],
7+ base: './',
8+})