<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Xuxuan Zhou</title>
    <description>The latest articles on DEV Community by Xuxuan Zhou (@xxzhou).</description>
    <link>https://dev.to/xxzhou</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3773665%2Fadb8c017-7dbb-46ba-8c8b-5d1c1d970167.png</url>
      <title>DEV Community: Xuxuan Zhou</title>
      <link>https://dev.to/xxzhou</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/xxzhou"/>
    <language>en</language>
    <item>
      <title>Why generate images through MCP instead of a standalone web UI</title>
      <dc:creator>Xuxuan Zhou</dc:creator>
      <pubDate>Tue, 15 Sep 2026 14:01:19 +0000</pubDate>
      <link>https://dev.to/xxzhou/why-generate-images-through-mcp-instead-of-a-standalone-web-ui-5428</link>
      <guid>https://dev.to/xxzhou/why-generate-images-through-mcp-instead-of-a-standalone-web-ui-5428</guid>
      <description>&lt;p&gt;Every image generator has a prompt box. You type what you want, click generate, and inspect the result. It works. But when you're already deep in a conversation with an AI assistant — describing a product, iterating on a brief, planning a campaign — switching to a separate web UI means reconstructing context that already exists.&lt;/p&gt;

&lt;p&gt;This article compares two approaches to the same creative task: using a standalone image generator, and using one connected to your AI conversation through MCP. I build &lt;a href="https://claudeimagine.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=2026w38&amp;amp;utm_content=mcp-vs-webui" rel="noopener noreferrer"&gt;Claude Imagine&lt;/a&gt;, a remote MCP service for image and video generation. It isn't affiliated with Anthropic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The context problem
&lt;/h2&gt;

&lt;p&gt;Here is a common scenario. You're in Claude, discussing a product campaign for a skincare brand. You've described the brand's color palette, the bottle shape, the mood you want. Now you need an image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With a standalone web UI&lt;/strong&gt;, you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Midjourney, DALL-E, or another generator in a new tab&lt;/li&gt;
&lt;li&gt;Write a new prompt from scratch, re-describing everything you just discussed&lt;/li&gt;
&lt;li&gt;Guess at the right parameters — aspect ratio, style, negative prompts&lt;/li&gt;
&lt;li&gt;Generate, download the result&lt;/li&gt;
&lt;li&gt;Return to your conversation, paste the image or describe what you got&lt;/li&gt;
&lt;li&gt;Repeat for every revision&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;With MCP&lt;/strong&gt;, you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Say "generate a product image based on what we just discussed"&lt;/li&gt;
&lt;li&gt;The assistant already has the brief, the brand details, the mood — it constructs the prompt&lt;/li&gt;
&lt;li&gt;The result appears in the conversation&lt;/li&gt;
&lt;li&gt;Say "change the background to pale pink" — the assistant references the previous image automatically&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The difference isn't just fewer clicks. It's that the assistant has been part of the creative discussion from the start. It doesn't need you to re-explain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "context-aware" means in practice
&lt;/h2&gt;

&lt;p&gt;In our September 5 test, we described a fictional skincare brand called LUMA in Chinese — the bottle material, the stone pedestal, a lime as a prop, soft studio lighting. Claude translated the creative brief into generation parameters without asking us to restate anything in a specific prompt format.&lt;/p&gt;

&lt;p&gt;Three things happened that wouldn't in a standalone tool:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Language didn't matter.&lt;/strong&gt; We described the scene in Chinese. Claude constructed the English prompt for the image model. A web UI would require you to write the prompt in the model's expected language yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The edit referenced the previous result automatically.&lt;/strong&gt; When we asked to change the background to pale pink, Claude's &lt;code&gt;edit_image&lt;/code&gt; call used the CDN address of the first generation. No download, no re-upload, no copy-pasting a file URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The video used the edited image without manual handoff.&lt;/strong&gt; Converting the revised image to a 4-second video meant Claude called &lt;code&gt;generate_video&lt;/code&gt; with the edit's output address. In a web workflow, this would be: download edited image → open video tool → upload → configure → generate.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Standalone web UI&lt;/th&gt;
&lt;th&gt;MCP in conversation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Describe what you want&lt;/td&gt;
&lt;td&gt;Write a new prompt from memory&lt;/td&gt;
&lt;td&gt;Assistant already has the brief&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Choose parameters&lt;/td&gt;
&lt;td&gt;Navigate model settings manually&lt;/td&gt;
&lt;td&gt;Assistant maps your words to API params&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edit&lt;/td&gt;
&lt;td&gt;Download → re-upload to editor&lt;/td&gt;
&lt;td&gt;"Change the background" — previous file referenced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image → video&lt;/td&gt;
&lt;td&gt;Download → open video tool → upload&lt;/td&gt;
&lt;td&gt;"Turn this into a short clip" — file address carried over&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Track cost&lt;/td&gt;
&lt;td&gt;Check account after generating&lt;/td&gt;
&lt;td&gt;Ask for a quote before, set a cap, verify after&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The cost control difference
&lt;/h2&gt;

&lt;p&gt;This is undersold. With most web UIs, you click "generate" and discover the cost afterward — or you check a pricing page and do the math yourself.&lt;/p&gt;

&lt;p&gt;Through MCP, the assistant can call &lt;code&gt;quote_generation&lt;/code&gt; before spending anything. In our test, we asked Claude to quote the cost and confirm our balance. Then we set &lt;code&gt;max_credits&lt;/code&gt; to 3 on the generation call. If the price exceeded that, the request would stop without generating.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use Claude Imagine to quote one nano-banana-2 image at 9:16.
Tell me the cost and my balance. Don't generate yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't just a convenience. It's a programmatic guard. The &lt;code&gt;max_credits&lt;/code&gt; parameter rejects a single call that exceeds the cap. A web UI has no equivalent of "refuse to run if it costs more than X."&lt;/p&gt;

&lt;h2&gt;
  
  
  When the web UI is still better
&lt;/h2&gt;

&lt;p&gt;A standalone tool wins when you want:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Visual prompt builders&lt;/strong&gt;: Midjourney's style references, DALL-E's editing canvas, and ComfyUI's node graphs are purpose-built interfaces. A text conversation can't replicate drag-and-drop composition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch exploration&lt;/strong&gt;: Generating a grid of 4 variations at once and visually comparing them side by side is faster in a dedicated UI than asking an assistant to generate and display them sequentially.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Community galleries&lt;/strong&gt;: Browsing what others have made, remixing public prompts, and discovering styles — these are social features that live in web platforms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time preview&lt;/strong&gt;: Painting over a region, adjusting a mask, or using ControlNet guidance requires a graphical interface.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MCP doesn't replace visual editing tools. It replaces the copy-paste-switch-tab-rewrite-prompt loop that breaks your working context.&lt;/p&gt;

&lt;h2&gt;
  
  
  How MCP connects technically
&lt;/h2&gt;

&lt;p&gt;For developers: MCP (Model Context Protocol) is Anthropic's open standard for connecting AI assistants to external tools. A remote MCP server exposes tools — functions the assistant can call — via a standardized protocol. Claude Imagine exposes tools like &lt;code&gt;generate_image&lt;/code&gt;, &lt;code&gt;edit_image&lt;/code&gt;, &lt;code&gt;generate_video&lt;/code&gt;, &lt;code&gt;quote_generation&lt;/code&gt;, and &lt;code&gt;list_generations&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The assistant sees these tools and their parameter schemas. When you describe what you want, it maps your natural language to tool calls. The generation happens server-side; the result (image URL, cost, task ID) returns to the conversation.&lt;/p&gt;

&lt;p&gt;This means any MCP-compatible client — claude.ai, Claude Desktop, Claude Code, or third-party clients — can use the same service without a separate integration per platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  A practical test you can run
&lt;/h2&gt;

&lt;p&gt;If you want to compare the two approaches yourself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pick a simple creative task — a product shot, a blog cover, an illustration&lt;/li&gt;
&lt;li&gt;First, do it in your preferred standalone generator. Note how long the prompt takes to write and how many tabs you switch between&lt;/li&gt;
&lt;li&gt;Then connect &lt;a href="https://claudeimagine.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=2026w38&amp;amp;utm_content=mcp-vs-webui" rel="noopener noreferrer"&gt;Claude Imagine&lt;/a&gt; as a custom connector in Claude and try the same task in conversation. Describe what you want in your own words&lt;/li&gt;
&lt;li&gt;Compare: which felt like less friction? Where did each approach do better?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The connection address for Claude's custom connectors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://claudeimagine.com/api/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The point isn't that one is categorically better. It's that they serve different moments in a workflow. When you're already talking to an AI assistant about your project, switching to a separate tool to create an image is a context break that MCP eliminates.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Claude Imagine. This article was prepared with AI assistance, using our recorded product tests from September 2026.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Generate an image in Claude with a remote MCP connector</title>
      <dc:creator>Xuxuan Zhou</dc:creator>
      <pubDate>Wed, 09 Sep 2026 12:41:52 +0000</pubDate>
      <link>https://dev.to/xxzhou/generate-an-image-in-claude-with-a-remote-mcp-connector-36hn</link>
      <guid>https://dev.to/xxzhou/generate-an-image-in-claude-with-a-remote-mcp-connector-36hn</guid>
      <description>&lt;p&gt;Connect Claude Imagine to Claude, check the price of one image, then generate it with a credit cap. The example here is a product shot for a fictional skincare brand called LUMA.&lt;/p&gt;

&lt;p&gt;In our September 5 Claude Desktop test, we asked for a vertical product image using Nano Banana 2. The request cost 3 credits, and the image appeared in an embedded card in the conversation.&lt;/p&gt;

&lt;p&gt;Disclosure: I build Claude Imagine, the independent service used here. It isn't affiliated with Anthropic.&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjl8ep5wtgtfyuthbnbr.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvjl8ep5wtgtfyuthbnbr.jpg" alt="AI-generated LUMA skincare product image" width="768" height="1376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;AI-generated example from our September 5 test. LUMA is a fictional product. This is the output image, not a screenshot of Claude.&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Connect the service
&lt;/h2&gt;

&lt;p&gt;Claude handles the conversation and the tool request. Claude Imagine sends the image request to an image-generation model. You'll need accounts for both services; their usage balances are separate.&lt;/p&gt;

&lt;p&gt;In Claude, find &lt;strong&gt;Customize → Connectors&lt;/strong&gt;, choose &lt;strong&gt;Add custom connector&lt;/strong&gt;, and enter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://claudeimagine.com/api/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Name it Claude Imagine. Add the connector, then use &lt;strong&gt;Connect&lt;/strong&gt; if authentication hasn't started. Sign in to Claude Imagine and approve the connection. Enable it for the conversation if necessary.&lt;/p&gt;

&lt;p&gt;Menu labels can change. Anthropic maintains the &lt;a href="https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp" rel="noopener noreferrer"&gt;current custom-connector instructions&lt;/a&gt;, including organization setup. Our September 5 generation test used an already connected Claude Desktop account; it didn't repeat account registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask for the cost first
&lt;/h2&gt;

&lt;p&gt;Start with a request that doesn't create anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use Claude Imagine to list the available image models and check my balance.
Quote one nano-banana-2 text-to-image generation at 9:16.
Don't generate an image yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The relevant tools are &lt;code&gt;list_models&lt;/code&gt;, &lt;code&gt;get_credits&lt;/code&gt;, and &lt;code&gt;quote_generation&lt;/code&gt;. The quote should tell you the cost and whether your account can use the requested model.&lt;/p&gt;

&lt;p&gt;Our recorded image cost 3 credits. Treat that as the cost of that test, not a permanent price promise. Check the quote returned for your account before continuing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate one image
&lt;/h2&gt;

&lt;p&gt;Here is a starter prompt for the same type of task. It's a reusable brief, not a transcript of the original test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Generate exactly one image using Claude Imagine and nano-banana-2 at 9:16.
Set max_credits to 3. If the request costs more, stop.

Create a product image for a fictional skincare brand named LUMA.
Place one bottle on a stone plinth, with a lime nearby and soft studio light.
Use a simple background and leave space above the bottle for a future headline.
Don't add a headline to the image.

Show the result using the available image card and include the original file link.
Don't generate alternatives or video.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Approve the tool call if Claude asks. The &lt;code&gt;max_credits&lt;/code&gt; value caps this individual request; it doesn't create a shared budget for future edits. If the quote exceeds the cap, choose a different model or deliberately change your budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the result you actually received
&lt;/h2&gt;

&lt;p&gt;In our Desktop test, the result appeared in an embedded card with an original-image link. That observation doesn't establish the same rendering behavior in Claude Code, mobile, or every web-client version.&lt;/p&gt;

&lt;p&gt;On September 8, we ran a separate English-language test in claude.ai: one new LUMA image with Nano Banana 2 at 16:9. Claude quoted 3 credits before generation and reported the same cost afterward. The finished image appeared in the conversation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fope6ipm7suqlmldpoa0i.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fope6ipm7suqlmldpoa0i.jpg" alt="A new LUMA image displayed in an English Claude web conversation" width="799" height="410"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Actual claude.ai screenshot, September 8. This AI-generated 16:9 image is a separate result from the September 5 vertical image above. The prompt shown in the card differs from the starter template. The screenshot is cropped to leave out private account details.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Check the tool result if you don't immediately see the preview. Keep the original link as well. If the file exists but the preview is missing, don't immediately ask Claude to generate again: retrieving a result and creating a second image are different operations.&lt;/p&gt;

&lt;p&gt;Inspect the image before using it. Product labels, bottle shape, and spacing need a visual check. A completed tool call confirms that a file was produced; it doesn't confirm that the image meets your brief.&lt;/p&gt;

&lt;p&gt;Once the first image works, you can try a separate edit. In our test, changing the background used &lt;code&gt;edit_image&lt;/code&gt; with the previous result's address, so we didn't download and re-upload the image. That edit was another paid operation.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://claudeimagine.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=2026w37&amp;amp;utm_content=w1a" rel="noopener noreferrer"&gt;Claude Imagine website&lt;/a&gt; has the connector setup and account access. Save the first result before expanding the workflow.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>tutorial</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Diagnosing a Broken STL File: Union-Find, Boundary Loops, and SDF Voxel Rebuilds</title>
      <dc:creator>Xuxuan Zhou</dc:creator>
      <pubDate>Tue, 14 Jul 2026 14:07:24 +0000</pubDate>
      <link>https://dev.to/xxzhou/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds-4a3o</link>
      <guid>https://dev.to/xxzhou/diagnosing-a-broken-stl-file-union-find-boundary-loops-and-sdf-voxel-rebuilds-4a3o</guid>
      <description>&lt;p&gt;A lot of 3D files are quietly broken. Not "won't open" broken — they load fine in a viewer, look like a normal model, and then a slicer either rejects them outright or silently produces garbage: missing walls, phantom internal cavities, infill leaking out through a gap you can't see. This happens constantly with AI-generated meshes and phone LiDAR scans, and it's rare enough with hand-modeled CAD files that most tutorials don't cover it well.&lt;/p&gt;

&lt;p&gt;We've spent the last few months building &lt;a href="https://meshrefinery.com" rel="noopener noreferrer"&gt;MeshRefinery&lt;/a&gt;, a mesh repair and format-conversion tool, and the interesting part turned out to be less "how do you fix a hole in a mesh" and more "how do you even &lt;em&gt;define&lt;/em&gt; broken, and how do you fix it without a full watertight-reconstruction pipeline when you don't need one." This post walks through both the cheap, entirely-client-side repair pass and the expensive, guaranteed-correct one, plus a color-preservation trick that isn't really about repair at all.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://meshrefinery.com/blog/diagnosing-broken-stl-files" rel="noopener noreferrer"&gt;MeshRefinery blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "broken" means to a slicer
&lt;/h2&gt;

&lt;p&gt;A mesh is a soup of triangles. A slicer needs it to describe a closed volume — for every point in space, "inside" or "outside" has to be unambiguous. Three properties matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Watertight&lt;/strong&gt;: no boundary edges. Every edge of every triangle should be shared by exactly one &lt;em&gt;other&lt;/em&gt; triangle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manifold&lt;/strong&gt;: no edge shared by more than two triangles. If three or more faces meet at an edge, "inside vs outside" stops being well-defined there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consistent winding / single shell where expected&lt;/strong&gt;: disconnected fragments (common in AI output — dozens of tiny floating shards) confuse a slicer's part detection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get all of this from one structure: an edge → face-count map.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildEdgeMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;indexArr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ArrayLike&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;edgeMap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;faceCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;indexArr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;faceCount&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;indexArr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;indexArr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;indexArr&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;v2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;v1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;v2&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;v1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;edgeMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
      &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;edgeMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;edgeMap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Count == 1 → boundary edge (a hole). Count &amp;gt; 2 → non-manifold. Count == 2 → fine. Run a union-find over faces connected by manifold edges and you get shell count for free — which doubles as an AI-slop detector: a model with 40+ disconnected components is almost never a real 40-part assembly, it's floating debris from generation noise.&lt;/p&gt;

&lt;p&gt;One catch that eats a surprising amount of debugging time: &lt;strong&gt;STL doesn't share vertices&lt;/strong&gt;. Every triangle stores three fresh corner points, so two triangles that meet at an edge in space have geometrically-identical-but-distinct vertex indices. Run edge analysis on raw STL data and everything looks like a boundary edge, because nothing is technically shared. You have to weld coincident vertices &lt;em&gt;first&lt;/em&gt;, within a tolerance — and the tolerance has to scale with the model, not be a fixed epsilon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;WELD_TOLERANCE_RATIO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// relative to bbox diagonal&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tolerance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;diag&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;WELD_TOLERANCE_RATIO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A fixed epsilon either welds legitimate close-but-distinct vertices on a tiny model, or fails to weld numerically-fuzzy duplicates on a large one. Scaling to the bounding-box diagonal fixes both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 1: light repair, entirely in the browser
&lt;/h2&gt;

&lt;p&gt;MeshRefinery's free tool runs this in-browser with three.js, no upload required — the file never leaves the client. The pipeline, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Weld&lt;/strong&gt; coincident vertices (above).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drop degenerate faces&lt;/strong&gt; — zero-area triangles, detected via the cross-product area test. Harmless but they pollute every downstream step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove floating fragments&lt;/strong&gt; — union-find shell detection, drop any shell under ~0.5% of total face count. This is the AI-slop cleanup: keep the model, discard the noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fan-fill small holes.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hole filling is the fiddly part. You can't just connect arbitrary boundary vertices — you need the &lt;em&gt;loop&lt;/em&gt;, walked in the winding order the surrounding surface already uses, so the new triangles come out facing the right way. The trick: build a &lt;em&gt;directed&lt;/em&gt; boundary-edge map instead of an undirected one. Each boundary edge belongs to exactly one face, so record it in that face's winding direction; the resulting map is a set of &lt;code&gt;v1 -&amp;gt; v2&lt;/code&gt; pointers that chain into a loop automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;traceLoops&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;nextMap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxLoopSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visited&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;[][]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;nextMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;nextMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cur&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;maxLoopSize&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;loops&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;loops&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each closed loop under the size cap gets a centroid vertex and a triangle fan. Cheap, and it's honestly all you need for the vast majority of "AI mesh has a few small gaps" cases.&lt;/p&gt;

&lt;p&gt;It is explicitly &lt;strong&gt;not&lt;/strong&gt; a general watertight guarantee. Fan-filling from a centroid works for small, roughly-planar loops. It does nothing useful for a hole with saddle topology, or one large enough that a flat fan would self-intersect, or a mesh that's non-manifold in ways no amount of hole-filling touches. Being honest about that boundary in the product copy — "this cleans up the common stuff, it will not rescue a genuinely broken mesh" — mattered more to user trust than we expected going in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tier 2: when fan-filling isn't enough
&lt;/h2&gt;

&lt;p&gt;For anything harder, the paid path throws away the fan-fill approach entirely and rebuilds the surface from a signed distance field:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Generalized winding number&lt;/strong&gt; (via &lt;code&gt;libigl&lt;/code&gt;) to classify points as inside/outside — this is the part that makes the whole approach robust to non-manifold and disconnected input; unlike ray casting, it degrades gracefully instead of returning garbage on bad topology.&lt;/li&gt;
&lt;li&gt;Evaluate that at every point on a voxel grid, turn it into a &lt;strong&gt;signed distance field&lt;/strong&gt; (Euclidean distance transform, &lt;code&gt;outside − inside&lt;/code&gt;), Gaussian-smooth it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marching cubes&lt;/strong&gt; over the SDF's zero-isosurface produces a brand-new, watertight, manifold mesh — by construction, not by patching.&lt;/li&gt;
&lt;li&gt;Drop any voxel-noise fragments under the same 0.5% face-count threshold as before, fix normals, and run a few iterations of Taubin smoothing to knock down the voxel staircasing without shrinking the volume the way naive Laplacian smoothing would.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tradeoff is real: you lose the original vertex positions and any UV/vertex-color data, because you have an entirely new mesh. So the last step is transferring color back — for every new vertex, find the nearest point on the &lt;em&gt;original&lt;/em&gt; surface, get its barycentric coordinates within that triangle, and interpolate the original triangle's vertex colors:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tri_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trimesh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;proximity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;closest_point&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dst&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vertices&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;bary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trimesh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triangles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;points_to_barycentric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;triangles&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tri_id&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;closest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src_colors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tri_id&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bary&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because "trust me, it's fine" isn't a great answer for something as lossy-sounding as a full SDF rebuild, the API also reports fidelity: sample points on the new surface, measure distance back to the original, express it as a percentage of the model's bounding-box diagonal. Mean error is typically well under 1% of diagonal — small enough not to matter for FDM printing, and now a number instead of a vibe.&lt;/p&gt;

&lt;h2&gt;
  
  
  A repair-adjacent problem: color survives the trip too
&lt;/h2&gt;

&lt;p&gt;This one isn't repair, it's format translation, but it hits the same "the naive version is wrong" pattern. GLB/glTF store color as a PBR base-color &lt;em&gt;texture&lt;/em&gt;, not per-vertex color — so a plain geometry load silently drops all color. To carry it into a vertex-colored format, bake the texture: sample the base-color image at each vertex's UV coordinate.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;u&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;py&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;py&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// R&lt;/span&gt;
&lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// G&lt;/span&gt;
&lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// B&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One color-space gotcha buried in there: &lt;code&gt;baseColorTexture&lt;/code&gt; is sRGB-encoded and 3MF colors are sRGB hex, so the raw &lt;code&gt;getImageData&lt;/code&gt; bytes map straight through with zero conversion — but three.js's flat &lt;code&gt;material.color&lt;/code&gt; is stored &lt;em&gt;linear&lt;/em&gt;, so that path needs an explicit &lt;code&gt;convertLinearToSRGB()&lt;/code&gt; or flat-colored meshes come out visibly wrong while textured ones look fine. Easy to miss because it only breaks one of the two code paths.&lt;/p&gt;

&lt;p&gt;3MF itself is worth a mention: it's just a zip (an OPC package, the same container format DOCX/XLSX use), holding three tiny XML parts — a content-types manifest, a relationships file, and the actual model. Colors go in an &lt;code&gt;&amp;lt;m:colorgroup&amp;gt;&lt;/code&gt;, deduped into a palette and referenced per-vertex-per-triangle — that's the exact structure Bambu Studio, PrusaSlicer, and Cura read to drive full-color/AMS printing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves things
&lt;/h2&gt;

&lt;p&gt;Two tiers, two very different engineering budgets: a fast, honest, client-side pass that fixes the common cases for free with zero upload, and a heavier server-side SDF rebuild for the cases that actually need a real watertight guarantee — with a measured fidelity number instead of a promise. If you're dealing with broken meshes yourself, both &lt;a href="https://meshrefinery.com/stl-repair" rel="noopener noreferrer"&gt;the diagnosis logic&lt;/a&gt; and the &lt;a href="https://meshrefinery.com/glb-to-3mf" rel="noopener noreferrer"&gt;format converters&lt;/a&gt; are free to try.&lt;/p&gt;

</description>
      <category>3dprinting</category>
      <category>threejs</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
