MeshMaker
I started working on MeshMaker around 2009 to build a 3D mesh editor with very basic features and learn by doing. That same year I switched completely to Mac OS X as my daily driver and wanted to learn Objective-C, Core Graphics, and JavaScriptCore while reusing my old C++ code. I still think the original Objective-C++ hybrid was the best and easiest way to share C++ logic with native Cocoa UI. Around the same time I used C++/CLI with C# for the Windows port and C++ with Qt for the Linux port. I developed it intensively until around 2015, then spent most of my energy on other projects, my day job, and family.
The original native macOS app, a walkthrough of quads and edge loops.
Recently I wanted to port it to the web so it would run on every platform with no install, and to see how far a large port could go driven purely by Claude and Copilot. It runs now.
Open the editor in your browser →
The web port in a browser tab, blocking out a tower and extruding the battlements.
The porting strategy
The original code was a tangle of C++/CLI and C#. The same .mm files were shared across platforms with #ifdef blocks, compiled as Objective-C++ on macOS and as C++/CLI on Windows. Jumping straight to WebGL2 would have meant debugging the language port and the graphics port at the same time, so I split it in two.
First came a clean-room WebGL2/ folder with a CMake build and the engine rewritten as plain C++17 against OpenGL 3.3, the desktop profile closest to WebGL2. Only once that ran natively did I add Emscripten as a second build target, where the same C++ compiles to WASM against WebGL2, which is OpenGL ES 3.0 in the browser. Every feature could be verified on the desktop build first, then again on the web.
That engine is about 22K lines of C++ handling mesh topology, selection, undo and redo, OBJ and glTF I/O, and UV unwrapping. Most of it came across from the original, including the subdivision. The one big algorithm the old app never had is the seam-based UV unwrap, a Least Squares Conformal Mapping solver built during the port with Claude and Copilot.
The WebGL2 port at filipkunc.com/meshmaker, a box-unwrapped textured cube in the UV editor.
Talking to the browser
The React frontend never touches the mesh data. The C++ engine exposes about a hundred functions through Emscripten’s embind, and React calls them straight on the WASM module.
EMSCRIPTEN_BINDINGS(meshmaker) {
using namespace emscripten;
// Primitives
function("addCube", &api_addCube);
function("addPlane", &api_addPlane);
function("addCylinder", &api_addCylinder);
function("addSphere", &api_addSphere);
function("addIcosahedron", &api_addIcosahedron);
// Edit mode (0=Items, 1=Vertices, 2=Triangles, 3=Edges)
function("getEditMode", &api_getEditMode);
function("setEditMode", &api_setEditMode); On the JavaScript side that is just module.addCube() or module.subdivideSelectedFaces(). React polls a handful of state getters every 100ms to keep the toolbar in sync, and that is the whole bridge.
Pixar’s subdivision in the browser
Smooth subdivision surfaces are fiddly to get right, and a hobby editor has no business hand-rolling them. MeshMaker never did. It has always leaned on Pixar’s OpenSubdiv for them. The port keeps that dependency, pulled in through CMake’s FetchContent, and compiles it to WASM alongside everything else, so the same library now runs in the browser.
The engine just hands it a topology descriptor and asks for a refinement. The one bit of real logic is choosing the scheme from the mesh itself, Catmull-Clark for quads and Loop for triangle meshes, then carrying the per-corner UVs through as a face-varying channel so textures survive the subdivision.
// Determine if all faces are triangles (use Loop) or not (use Catmull-Clark)
bool allTriangles = true;
for (const auto& face : faces) {
if (face.isQuad()) {
allTriangles = false;
break;
}
}
Sdc::SchemeType scheme = allTriangles
? Sdc::SCHEME_LOOP
: Sdc::SCHEME_CATMARK;
// ...pack the faces, vertices, and per-corner UVs into a TopologyDescriptor...
Sdc::Options sdc_options;
sdc_options.SetVtxBoundaryInterpolation(Sdc::Options::VTX_BOUNDARY_EDGE_ONLY);
sdc_options.SetFVarLinearInterpolation(Sdc::Options::FVAR_LINEAR_CORNERS_ONLY);
Far::TopologyRefiner* refiner = Far::TopologyRefinerFactory<Far::TopologyDescriptor>::Create(
desc,
Far::TopologyRefinerFactory<Far::TopologyDescriptor>::Options(scheme, sdc_options)
);
refiner->RefineUniform(Far::TopologyRefiner::UniformOptions(level)); So clicking Subdivide in a browser tab runs the same library Pixar uses in film production, on a mesh I can go back to editing by hand a second later.
The tests that kept Claude honest
The original desktop app shipped with almost no automated tests, just two from 2009. That was fine while a human drove every change, but as soon as the port reached the mesh editing operations the models started hallucinating and quietly breaking things that already worked. Adding GoogleTest to the engine was the turning point. None of it existed before the port, and building it out was what made the rest possible. I serialized my manual test cases into more than 130 of them, covering transforms, UV mapping, seam marking, conformal unwrapping, subdivision, and edge splitting, plus 22 Playwright tests driving the real React UI.
A unit test pins the exact result of an operation so the model has nothing to negotiate. Catmull-Clark on the cube from the last section has one right answer, and this test spells out every count:
TEST_F(CatmullClarkTest, CubeLevel1_ProducesExpectedCounts) {
mesh.makeCube();
size_t origVerts = mesh.getVertexCount();
size_t origFaces = mesh.getFaceCount();
size_t origEdges = mesh.getEdgeCount();
// A cube has 8 verts, 6 faces, 12 edges
ASSERT_EQ(origVerts, 8u);
ASSERT_EQ(origFaces, 6u);
ASSERT_EQ(origEdges, 12u);
mesh.catmullClarkSubdivide(1);
// After Catmull-Clark level 1 on a cube:
// New verts = original verts + edge midpoints + face centers = 8 + 12 + 6 = 26
// New faces = 4 per original face = 6 * 4 = 24
EXPECT_EQ(mesh.getVertexCount(), 26u);
EXPECT_EQ(mesh.getFaceCount(), 24u);
// All faces should be quads after Catmull-Clark on a quad mesh
for (size_t i = 0; i < mesh.getFaceCount(); i++) {
EXPECT_TRUE(mesh.getFace(i).isQuad()) << "Face " << i << " should be a quad";
}
} The Playwright tests cover the other half, clicking the real toolbar and reading scene state back out of the WASM module. This one builds a scene, exports it to OBJ, clears, then reimports, all through the actual UI:
// getItemCount reads scene state straight from the WASM module.
const getItemCount = (page: Page) =>
page.evaluate(() => (window as any).Module?.getItemCount() ?? 0);
test('OBJ round-trip: export then import restores items', async ({ page }) => {
await page.click('button[title="Clear Scene"]');
await page.click('button[title="Add Cube"]');
await page.click('button[title="Add Sphere"]');
expect(await getItemCount(page)).toBe(2);
// Export the scene to OBJ text through the engine.
const objData = await page.evaluate(() =>
(window as any).Module.exportToOBJ() as string);
await page.click('button[title="Clear Scene"]');
expect(await getItemCount(page)).toBe(0);
// Re-import and confirm the items come back.
const success = await page.evaluate(
(data) => (window as any).Module.importFromOBJ(data) as boolean,
objData);
expect(success).toBe(true);
expect(await getItemCount(page)).toBeGreaterThan(0);
}); Each operation had to keep passing before the next one was allowed to land, and from there almost everything landed with steering alone. One feature I still had to fix entirely by hand, because at the time I could not stop Claude from breaking it.
I would put the port at well over 90% written by AI, which still surprises me.
Scripting and generation
Two later additions were almost pure steering. An in-browser scripting console wires a Monaco editor to about 150 of those same engine functions, so a few lines of JavaScript can drive the modeler. The one real snag was Emscripten’s GLFW stealing keystrokes from the editor, which I fixed with a little runtime monkey patching. Adding Hunyuan3D-2 for image and text to 3D was mostly AI work too, and there the limiting factor was my GPU. Generating a textured model pushed past 16 GB of VRAM and crashed often.
The port lives on the webgl2-port branch. Open the editor and build something.