<?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: Bubu Dong</title>
    <description>The latest articles on DEV Community by Bubu Dong (@bubudong).</description>
    <link>https://dev.to/bubudong</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%2F3769947%2F9df67d90-2e18-4692-88c5-3935e5bc82dc.png</url>
      <title>DEV Community: Bubu Dong</title>
      <link>https://dev.to/bubudong</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bubudong"/>
    <language>en</language>
    <item>
      <title>Previewing a Gaussian Splat .ply in three.js without a splat renderer</title>
      <dc:creator>Bubu Dong</dc:creator>
      <pubDate>Mon, 02 Nov 2026 03:39:08 +0000</pubDate>
      <link>https://dev.to/bubudong/previewing-a-gaussian-splat-ply-in-threejs-without-a-splat-renderer-1o3f</link>
      <guid>https://dev.to/bubudong/previewing-a-gaussian-splat-ply-in-threejs-without-a-splat-renderer-1o3f</guid>
      <description>&lt;p&gt;If you've trained a 3D Gaussian Splat, or exported one from a capture app, you end up with a &lt;code&gt;.ply&lt;/code&gt; file. Open it in a normal 3D viewer and you get one of three things: an error, a cloud of grey dots, or a tangle of random triangles.&lt;/p&gt;

&lt;p&gt;I work on a browser-based PLY viewer, and I wanted it to answer one question quickly: &lt;em&gt;what is in this file?&lt;/em&gt; A full splat renderer is a bigger project, so the goal here was a fast preview with the right colors, the near-invisible splats left out, and the camera aimed at the subject.&lt;/p&gt;

&lt;p&gt;The file I tested with is the &lt;strong&gt;bonsai&lt;/strong&gt; scene from the Mip-NeRF 360 dataset (trained splat from the &lt;a href="https://huggingface.co/datasets/dylanebert/3dgs" rel="noopener noreferrer"&gt;dylanebert/3dgs&lt;/a&gt; collection on Hugging Face). It has 1,157,141 splats in 287 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. It's a point cloud, not a mesh
&lt;/h2&gt;

&lt;p&gt;A splat PLY has a &lt;code&gt;vertex&lt;/code&gt; element and no &lt;code&gt;face&lt;/code&gt; element. three.js's &lt;code&gt;PLYLoader&lt;/code&gt; gives you a &lt;code&gt;BufferGeometry&lt;/code&gt; with no index. If you pass that to a &lt;code&gt;THREE.Mesh&lt;/code&gt;, which is what a generic viewer does, three.js treats every three consecutive vertices as a triangle. That's where the random-triangle tangle comes from.&lt;/p&gt;

&lt;p&gt;The fix is to check for an index and use &lt;code&gt;THREE.Points&lt;/code&gt; when there isn't one:&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getIndex&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="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Points&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PointsMaterial&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pointSize&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;sizeAttenuation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;vertexColors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hasColor&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Detecting a splat from the header
&lt;/h2&gt;

&lt;p&gt;The header tells you which kind of PLY you have. Here's the bonsai file (with the 45 &lt;code&gt;f_rest_*&lt;/code&gt; lines shortened):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;format binary_little_endian 1.0
element vertex 1157141
property float x
property float y
property float z
property float nx
property float ny
property float nz
property float f_dc_0
property float f_dc_1
property float f_dc_2
property float f_rest_0
...
property float f_rest_44
property float opacity
property float scale_0
property float scale_1
property float scale_2
property float rot_0
property float rot_1
property float rot_2
property float rot_3
end_header
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are no &lt;code&gt;red/green/blue&lt;/code&gt; properties. A &lt;code&gt;f_dc_0&lt;/code&gt; property is a reliable sign that the file is a splat. Each splat is 62 floats, or 248 bytes. The 45 &lt;code&gt;f_rest_*&lt;/code&gt; values make up &lt;strong&gt;72.6% of the file&lt;/strong&gt;. They hold view-dependent color, and a preview can ignore them.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Turning &lt;code&gt;f_dc&lt;/code&gt; into a color
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;f_dc_0..2&lt;/code&gt; are the zero-order spherical-harmonic coefficients for R, G and B. The base color is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;color = 0.5 + SH_C0 * f_dc      where SH_C0 = 0.28209479177387814
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;PLYLoader&lt;/code&gt; ignores properties it doesn't know about unless you map them to an attribute name:&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;loader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PLYLoader&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setCustomPropertyNameMapping&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;shDc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;f_dc_0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;f_dc_1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;f_dc_2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;splatOpacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opacity&lt;/span&gt;&lt;span class="dl"&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;geometry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buffer&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;dc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;shDc&lt;/span&gt;&lt;span class="dl"&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;colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Float32Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dc&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="mi"&gt;3&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Color&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;clamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&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;=&amp;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="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="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;SH_C0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;v&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;i&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&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="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="nf"&gt;setRGB&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getZ&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SRGBColorSpace&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="nf"&gt;set&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;r&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;g&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;b&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="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;geometry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;color&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;BufferAttribute&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="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pass &lt;code&gt;THREE.SRGBColorSpace&lt;/code&gt; to &lt;code&gt;setRGB&lt;/code&gt;. The vertex &lt;code&gt;color&lt;/code&gt; attribute is expected in linear space, and this value is a display color. If you skip the conversion, the colors come out washed out.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Most splats are almost invisible
&lt;/h2&gt;

&lt;p&gt;This one surprised me. Opacity is stored as a logit, so the real alpha is &lt;code&gt;sigmoid(opacity)&lt;/code&gt;. Across the bonsai file:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;alpha below&lt;/th&gt;
&lt;th&gt;share of splats&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.05&lt;/td&gt;
&lt;td&gt;21.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.10&lt;/td&gt;
&lt;td&gt;37.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.25&lt;/td&gt;
&lt;td&gt;61.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.50&lt;/td&gt;
&lt;td&gt;79.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A splat renderer blends all of these and the faint ones barely show. A point renderer draws every one as a solid dot, so more than a third of the points are haze that shouldn't be visible.&lt;/p&gt;

&lt;p&gt;I drop splats below 10% alpha. The comparison happens in logit space, so there's no &lt;code&gt;exp&lt;/code&gt; per point:&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;MIN_ALPHA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&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;minLogit&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;MIN_ALPHA&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MIN_ALPHA&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// ≈ -2.197&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;i&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;n&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="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;opacity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;minLogit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;keep&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;i&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;The viewer shows how many it skipped, so nothing disappears silently.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Framing: aim at the subject, not at the floaters
&lt;/h2&gt;

&lt;p&gt;My viewer fits the camera to the object's bounding sphere. On a trained splat, a handful of stray points far from the scene stretch that sphere. Here are the bonsai splats' distances from their median position:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;percentile&lt;/th&gt;
&lt;th&gt;distance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50th&lt;/td&gt;
&lt;td&gt;8.99&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;90th&lt;/td&gt;
&lt;td&gt;14.42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;99th&lt;/td&gt;
&lt;td&gt;18.63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max&lt;/td&gt;
&lt;td&gt;37.81&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The farthest point is twice as far out as the 99th percentile, so the scene took up a small patch in the middle of the view. For point clouds I now sample up to 20k points, take the per-axis median as the center and the 95th-percentile distance as the radius:&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;center&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;THREE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Vector3&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ys&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;zs&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;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;hypot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;center&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ys&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;center&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;zs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;center&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sort&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&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;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dist&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;dist&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="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;)];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use this sphere only when it's much tighter than the exact bounds. A clean scan with no outliers keeps the exact fit, so nothing gets clipped.&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%2Fvbi2mhmg7c7hnc3b79ob.png" 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%2Fvbi2mhmg7c7hnc3b79ob.png" alt="Before and after: the same splat with every point drawn and framed on all points, versus near-transparent splats skipped and framed on the 95th percentile" width="800" height="285"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Point size
&lt;/h2&gt;

&lt;p&gt;One fixed &lt;code&gt;size&lt;/code&gt; doesn't work, because scenes range from millimetres to hundreds of metres. I scale it from the bounding-box diagonal and the point count, then clamp it:&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;pointSize&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;diag&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;50&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="nx"&gt;diag&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Denser clouds get smaller points, and the clamps stop it going to extremes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is &lt;em&gt;not&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;A real splat renderer draws each splat as an oriented, stretched ellipse (&lt;code&gt;scale_*&lt;/code&gt;, &lt;code&gt;rot_*&lt;/code&gt;), sorts the splats by depth, alpha-blends them, and evaluates the view-dependent color (&lt;code&gt;f_rest_*&lt;/code&gt;). None of that happens here. The goal is a quick check of what a file contains, the kind of check you'd do before loading it into a real splat tool. The viewer says this on screen too.&lt;/p&gt;

&lt;p&gt;Two more limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The viewer takes files up to 200 MB. The full bonsai file is 287 MB, so for the screenshots I kept every second splat (578,571 of them).&lt;/li&gt;
&lt;li&gt;A splat has no surface, so it can't be exported as STL or 3MF. The viewer refuses with a clear message instead of writing an empty file. If you need a mesh, reconstruct a surface first (for example, Poisson reconstruction in MeshLab or CloudCompare).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://flip3d.app/ply-viewer/" rel="noopener noreferrer"&gt;flip3d.app/ply-viewer&lt;/a&gt;&lt;/strong&gt; opens splat PLYs, scanner point clouds and colored meshes. It also shows the file's header fields, so you can see exactly which properties are in the file. Everything runs in your browser and the file isn't uploaded. The code is in the &lt;a href="https://github.com/suyfdong/flip3d" rel="noopener noreferrer"&gt;repo&lt;/a&gt;: &lt;code&gt;lib/converters/parse.ts&lt;/code&gt; (reading and filtering) and &lt;code&gt;components/MeshViewer.tsx&lt;/code&gt; (framing).&lt;/p&gt;

&lt;p&gt;If you have a PLY that shows up wrong, send it my way in the comments.&lt;/p&gt;

</description>
      <category>threejs</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>3dprinting</category>
    </item>
    <item>
      <title>How Bambu Studio stores multi-color painting in a 3MF — decoding paint_color in the browser</title>
      <dc:creator>Bubu Dong</dc:creator>
      <pubDate>Thu, 15 Oct 2026 08:01:00 +0000</pubDate>
      <link>https://dev.to/bubudong/how-bambu-studio-stores-multi-color-painting-in-a-3mf-decoding-paintcolor-in-the-browser-20n7</link>
      <guid>https://dev.to/bubudong/how-bambu-studio-stores-multi-color-painting-in-a-3mf-decoding-paintcolor-in-the-browser-20n7</guid>
      <description>&lt;p&gt;Download a multi-color model from MakerWorld, open the &lt;code&gt;.3mf&lt;/code&gt; in almost any viewer other than Bambu Studio, and you get a single gray (or single-color) blob. The colors are in the file, though. They just aren't stored the way most 3D software expects.&lt;/p&gt;

&lt;p&gt;I recently added support for this to a browser-based 3MF viewer I work on, and the format turned out to be compact and pretty neat. Here's how it works.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Background:&lt;/strong&gt; in &lt;a href="https://dev.to/bubudong/why-prusaslicer-cant-open-your-bambu-3mf-and-how-i-flattened-the-3mf-production-extension-in-the-3p36"&gt;an earlier post&lt;/a&gt; I covered why PrusaSlicer can't open Bambu 3MFs at all: the geometry is split across &lt;code&gt;3D/Objects/*.model&lt;/code&gt; files using the 3MF production extension. This post assumes you've already flattened that and have the triangles.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of color in a Bambu 3MF
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Per-object filament.&lt;/strong&gt; Each object (or part) is assigned to a filament slot. That lives in &lt;code&gt;Metadata/model_settings.config&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"2"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;metadata&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"name"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"Cat"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;metadata&lt;/span&gt; &lt;span class="na"&gt;key=&lt;/span&gt;&lt;span class="s"&gt;"extruder"&lt;/span&gt; &lt;span class="na"&gt;value=&lt;/span&gt;&lt;span class="s"&gt;"3"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual colors are in &lt;code&gt;Metadata/project_settings.config&lt;/code&gt;, which is JSON despite the extension:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"filament_colour"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"#202020"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#C12E1F"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#FF6A13"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"filament_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PLA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PLA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;extruder = 3&lt;/code&gt; means "use &lt;code&gt;filament_colour[2]&lt;/code&gt;". That alone gets you a correctly tinted model when each color is a separate part.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Per-triangle painting.&lt;/strong&gt; When someone uses the paint brush in Bambu Studio (or Orca), the color goes on the triangles themselves, as an extra attribute:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;triangle&lt;/span&gt; &lt;span class="na"&gt;v1=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt; &lt;span class="na"&gt;v2=&lt;/span&gt;&lt;span class="s"&gt;"11"&lt;/span&gt; &lt;span class="na"&gt;v3=&lt;/span&gt;&lt;span class="s"&gt;"12"&lt;/span&gt; &lt;span class="na"&gt;paint_color=&lt;/span&gt;&lt;span class="s"&gt;"4"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;triangle&lt;/span&gt; &lt;span class="na"&gt;v1=&lt;/span&gt;&lt;span class="s"&gt;"12"&lt;/span&gt; &lt;span class="na"&gt;v2=&lt;/span&gt;&lt;span class="s"&gt;"13"&lt;/span&gt; &lt;span class="na"&gt;v3=&lt;/span&gt;&lt;span class="s"&gt;"10"&lt;/span&gt; &lt;span class="na"&gt;paint_color=&lt;/span&gt;&lt;span class="s"&gt;"8"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;triangle&lt;/span&gt; &lt;span class="na"&gt;v1=&lt;/span&gt;&lt;span class="s"&gt;"14"&lt;/span&gt; &lt;span class="na"&gt;v2=&lt;/span&gt;&lt;span class="s"&gt;"15"&lt;/span&gt; &lt;span class="na"&gt;v3=&lt;/span&gt;&lt;span class="s"&gt;"16"&lt;/span&gt; &lt;span class="na"&gt;paint_color=&lt;/span&gt;&lt;span class="s"&gt;"4C"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;triangle&lt;/span&gt; &lt;span class="na"&gt;v1=&lt;/span&gt;&lt;span class="s"&gt;"16"&lt;/span&gt; &lt;span class="na"&gt;v2=&lt;/span&gt;&lt;span class="s"&gt;"17"&lt;/span&gt; &lt;span class="na"&gt;v3=&lt;/span&gt;&lt;span class="s"&gt;"18"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A triangle without the attribute uses the object's own filament. PrusaSlicer writes exactly the same encoding under a different name, &lt;code&gt;slic3rpe:mmu_segmentation&lt;/code&gt;, because Bambu Studio forked it from PrusaSlicer's &lt;code&gt;TriangleSelector&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decoding the hex string
&lt;/h2&gt;

&lt;p&gt;It isn't a color, and it isn't a plain slot number either. It's a serialized &lt;strong&gt;subdivision tree&lt;/strong&gt;. When you paint across part of a triangle, the slicer splits that triangle into smaller ones and paints those. The string stores the whole tree, one hex digit (nibble) per node.&lt;/p&gt;

&lt;p&gt;Two things are unusual:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You read the string &lt;strong&gt;from the last character backwards&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Each nibble packs two fields: the low 2 bits are the number of split sides, the high 2 bits are the color (for leaves).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nibble = 0b SS CC         (read right to left through the string)
        CC = low 2 bits  = split sides
        SS = high 2 bits = state, when this node is a leaf

split == 0  → leaf
   SS &amp;lt; 3   → state = SS
   SS == 3  → state = (next nibble) + 3     // escape for slots ≥ 3
split &amp;gt; 0   → node has split + 1 children; decode them next, recursively
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;State &lt;code&gt;0&lt;/code&gt; means "unpainted, use the object's filament". State &lt;code&gt;n&lt;/code&gt; means filament slot &lt;code&gt;n&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Some real values from a MakerWorld model:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;paint_color&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;read as&lt;/th&gt;
&lt;th&gt;slot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;4&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;0100&lt;/code&gt;: leaf, SS=1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;8&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;1000&lt;/code&gt;: leaf, SS=2&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;0C&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;C&lt;/code&gt;=&lt;code&gt;1100&lt;/code&gt;: leaf, SS=3, escape → next nibble &lt;code&gt;0&lt;/code&gt; + 3&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;4C&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;escape → &lt;code&gt;4&lt;/code&gt; + 3&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;44043&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;3&lt;/code&gt;: split into 4 children → &lt;code&gt;4&lt;/code&gt;,&lt;code&gt;0&lt;/code&gt;,&lt;code&gt;4&lt;/code&gt;,&lt;code&gt;4&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;mixed: 1,0,1,1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In TypeScript:&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;decodePaintState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hex&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;1&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;next&lt;/span&gt; &lt;span class="o"&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="nx"&gt;pos&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&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="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hex&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="o"&gt;--&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weight&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;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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;visit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;w&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="nx"&gt;depth&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;=&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;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&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;split&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mb"&gt;0b11&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;split&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;16&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;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="mb"&gt;0b1100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mb"&gt;0b1100&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&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;code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;weight&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;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weight&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;state&lt;/span&gt;&lt;span class="p"&gt;)&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="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="k"&gt;return&lt;/span&gt;&lt;span class="p"&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;let&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;0&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;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;split&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;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;visit&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;split&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;depth&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="p"&gt;};&lt;/span&gt;

  &lt;span class="nf"&gt;visit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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="c1"&gt;// Dominant color wins&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;best&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;bestW&lt;/span&gt; &lt;span class="o"&gt;=&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="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;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;weight&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;w&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;bestW&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;best&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;bestW&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;best&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;h2&gt;
  
  
  The simplification I made (and why)
&lt;/h2&gt;

&lt;p&gt;A faithful renderer would rebuild the subdivided triangles and color each one. For a viewer I didn't need that. I give each original triangle the color of whichever leaf state covers most of it, weighting children equally at each split level. It's approximate, but:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On the model I tested (221,124 triangles, 7 filaments) only a small share of triangles are split at all; most are single leaves like &lt;code&gt;4&lt;/code&gt; or &lt;code&gt;8&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Decoding the whole file takes about &lt;strong&gt;60 ms&lt;/strong&gt; in the browser.&lt;/li&gt;
&lt;li&gt;Visually you lose only the sub-triangle detail along color borders, so a border can look slightly jagged.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wiring it into three.js
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ThreeMFLoader&lt;/code&gt; drops unknown attributes, so I scan the XML myself with a regex pass (fast enough at this size), collect a &lt;code&gt;Uint8Array&lt;/code&gt; of states per object, and then match each painted object to the mesh the loader produced:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Match by triangle count plus the first vertex position (the loader keeps vertex order).&lt;/li&gt;
&lt;li&gt;Convert the geometry to non-indexed so each triangle owns its 3 vertices.&lt;/li&gt;
&lt;li&gt;Write a &lt;code&gt;color&lt;/code&gt; attribute: state 0 → object filament, state n → &lt;code&gt;filament_colour[n-1]&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Use a white material with &lt;code&gt;vertexColors: true&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One gotcha: three.js expects the &lt;code&gt;color&lt;/code&gt; attribute in &lt;strong&gt;linear&lt;/strong&gt; space, but filament colors are sRGB hex. &lt;code&gt;new THREE.Color("#C12E1F")&lt;/code&gt; does the conversion for you (color management is on by default since r152). If you parse the hex bytes yourself and divide by 255, you skip that step and the colors come out washed out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The viewer is at &lt;strong&gt;&lt;a href="https://flip3d.app/3mf-viewer/" rel="noopener noreferrer"&gt;flip3d.app/3mf-viewer&lt;/a&gt;&lt;/strong&gt;. Drop a Bambu or PrusaSlicer 3MF on it and you get the painted colors, plates, filament list and the slicer's own thumbnail. It runs entirely in your browser, so the file never gets uploaded. The decoder is in the &lt;a href="https://github.com/suyfdong/flip3d" rel="noopener noreferrer"&gt;repo&lt;/a&gt; at &lt;code&gt;lib/converters/3mf-paint.ts&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you have a 3MF that renders wrong, I'd like to see it. Leave a comment.&lt;/p&gt;

</description>
      <category>3dprinting</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>threejs</category>
    </item>
    <item>
      <title>Why PrusaSlicer can't open your Bambu 3MF — and how I flattened the 3MF production extension in the browser</title>
      <dc:creator>Bubu Dong</dc:creator>
      <pubDate>Wed, 27 May 2026 07:05:22 +0000</pubDate>
      <link>https://dev.to/bubudong/why-prusaslicer-cant-open-your-bambu-3mf-and-how-i-flattened-the-3mf-production-extension-in-the-3p36</link>
      <guid>https://dev.to/bubudong/why-prusaslicer-cant-open-your-bambu-3mf-and-how-i-flattened-the-3mf-production-extension-in-the-3p36</guid>
      <description>&lt;p&gt;You find a great model on MakerWorld, download the &lt;code&gt;.3mf&lt;/code&gt;, open PrusaSlicer to tweak it... and PrusaSlicer just won't load it. No mesh, or an error, or an empty plate. The file isn't corrupt — Bambu Studio opens it fine. So what's going on?&lt;/p&gt;

&lt;p&gt;I went down this rabbit hole after a real bug report on a PrusaSlicer GitHub issue, fixed it, and the fix runs entirely in the browser. Here's the anatomy of the problem and how the flattening works.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 3MF is just a zip
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.3mf&lt;/code&gt; is a zip archive. Rename one to &lt;code&gt;.zip&lt;/code&gt;, unzip it, and a simple one looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3D/3dmodel.model        ← the geometry (XML)
[Content_Types].xml
_rels/.rels
Metadata/...            ← thumbnails, slicer settings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;3dmodel.model&lt;/code&gt; holds &lt;code&gt;&amp;lt;vertices&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;triangles&amp;gt;&lt;/code&gt; inside &lt;code&gt;&amp;lt;resources&amp;gt;&lt;/code&gt;, and a &lt;code&gt;&amp;lt;build&amp;gt;&lt;/code&gt; block that places those objects on the plate. Any slicer that speaks vanilla 3MF reads this and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bambu uses the "production extension"
&lt;/h2&gt;

&lt;p&gt;Open a Bambu Studio 3MF and the root model looks very different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;model&lt;/span&gt; &lt;span class="na"&gt;requiredextensions=&lt;/span&gt;&lt;span class="s"&gt;"p"&lt;/span&gt; &lt;span class="na"&gt;xmlns:p=&lt;/span&gt;&lt;span class="s"&gt;"http://schemas.microsoft.com/3dmanufacturing/production/2015/06"&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;resources&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;object&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="na"&gt;p:UUID=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;components&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;component&lt;/span&gt; &lt;span class="na"&gt;p:path=&lt;/span&gt;&lt;span class="s"&gt;"/3D/Objects/object_1.model"&lt;/span&gt; &lt;span class="na"&gt;objectid=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="na"&gt;p:UUID=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;component&lt;/span&gt; &lt;span class="na"&gt;p:path=&lt;/span&gt;&lt;span class="s"&gt;"/3D/Objects/object_2.model"&lt;/span&gt; &lt;span class="na"&gt;objectid=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="na"&gt;p:UUID=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
        ...
      &lt;span class="nt"&gt;&amp;lt;/components&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/object&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/resources&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;build&amp;gt;&lt;/span&gt;...&lt;span class="nt"&gt;&amp;lt;/build&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/model&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice: &lt;strong&gt;the root model has no geometry of its own.&lt;/strong&gt; It declares &lt;code&gt;requiredextensions="p"&lt;/code&gt; (the 3MF &lt;em&gt;production extension&lt;/em&gt;) and points at a pile of external part files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3D/3dmodel.model               ← no meshes, just &amp;lt;component p:path=...&amp;gt; references
3D/Objects/object_1.model      ← actual &amp;lt;vertices&amp;gt;/&amp;lt;triangles&amp;gt;
3D/Objects/object_2.model
... (one real file had 25 of these)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The production extension exists to split big assemblies across files and stream them. Bambu Studio leans on it heavily. The catch: &lt;strong&gt;PrusaSlicer doesn't implement the production extension.&lt;/strong&gt; It sees &lt;code&gt;requiredextensions="p"&lt;/code&gt;, sees a root model with no geometry, and gives up. That's the whole bug.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: flatten it back to vanilla 3MF
&lt;/h2&gt;

&lt;p&gt;The goal is to produce a single self-contained &lt;code&gt;3dmodel.model&lt;/code&gt; with all geometry inlined, no &lt;code&gt;p:&lt;/code&gt; namespace, no external parts — the boring kind of 3MF every slicer reads. Sounds simple; the fiddly part is the ID namespaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each part file has its own local &lt;code&gt;object id&lt;/code&gt; space.&lt;/strong&gt; A &lt;code&gt;&amp;lt;component objectid="1"&amp;gt;&lt;/code&gt; inside the root refers to id &lt;code&gt;1&lt;/code&gt; &lt;em&gt;in the file named by &lt;code&gt;p:path&lt;/code&gt;&lt;/em&gt;, not id &lt;code&gt;1&lt;/code&gt; in the root. So you can't just concatenate — you have to remap.&lt;/p&gt;

&lt;p&gt;The flattening steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Assign globally-unique object ids&lt;/strong&gt; across every &lt;code&gt;.model&lt;/code&gt; file in the archive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline external meshes in dependency order&lt;/strong&gt; — topological sort so leaf meshes come before the assemblies that reference them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite every reference&lt;/strong&gt;: each &lt;code&gt;p:path&lt;/code&gt; + &lt;code&gt;objectid&lt;/code&gt; pair, same-file &lt;code&gt;&amp;lt;component&amp;gt;&lt;/code&gt; references, and the &lt;code&gt;&amp;lt;build&amp;gt;&lt;/code&gt; item &lt;code&gt;objectid&lt;/code&gt;s, all repointed to the new global ids.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strip the extension&lt;/strong&gt;: drop &lt;code&gt;requiredextensions&lt;/code&gt;, the production (&lt;code&gt;p&lt;/code&gt;) namespace, and Bambu's vendor namespace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emit a clean container&lt;/strong&gt;: a minimal &lt;code&gt;[Content_Types].xml&lt;/code&gt; and &lt;code&gt;_rels/.rels&lt;/code&gt; (this also fixes dangling thumbnail relationships).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why I did it with string ops, not a DOM parser
&lt;/h2&gt;

&lt;p&gt;The transform only touches &lt;em&gt;structural&lt;/em&gt; tags — &lt;code&gt;&amp;lt;object&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;component&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;build&amp;gt;&lt;/code&gt;, the namespace declarations. It never needs to read or rewrite the &lt;code&gt;&amp;lt;vertex&amp;gt;&lt;/code&gt; / &lt;code&gt;&amp;lt;triangle&amp;gt;&lt;/code&gt; bodies, which are the huge part.&lt;/p&gt;

&lt;p&gt;So instead of pulling in a DOM/XML parser (heavy, and browser/Node behave differently), it's careful string manipulation over the structural elements. Two payoffs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It runs &lt;strong&gt;identically in Node and the browser&lt;/strong&gt;, so I could validate the exact same code against a real file on the command line.&lt;/li&gt;
&lt;li&gt;It's fast and memory-light even on multi-megabyte files.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Did it actually work?
&lt;/h2&gt;

&lt;p&gt;The bug report came with a real 6.3 MB file from MakerWorld — 25 external object models. After flattening:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;151,317 vertices&lt;/strong&gt; in → &lt;strong&gt;151,317&lt;/strong&gt; out&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;302,542 triangles&lt;/strong&gt; in → &lt;strong&gt;302,542&lt;/strong&gt; out&lt;/li&gt;
&lt;li&gt;all 25 external files merged into one root model&lt;/li&gt;
&lt;li&gt;every reference resolved, no forward references, XML well-formed&lt;/li&gt;
&lt;li&gt;zero production-extension residue&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then the actual test: the flattened file &lt;strong&gt;opens in PrusaSlicer.&lt;/strong&gt; Same geometry, now as a vanilla 3MF.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it / steal it
&lt;/h2&gt;

&lt;p&gt;I put this in a free, browser-only tool — drop a Bambu &lt;code&gt;.3mf&lt;/code&gt;, get a Prusa-friendly &lt;code&gt;.3mf&lt;/code&gt; back, nothing uploaded: &lt;strong&gt;&lt;a href="https://flip3d.app/tools/bambu-3mf-to-prusa/" rel="noopener noreferrer"&gt;flip3d.app/tools/bambu-3mf-to-prusa&lt;/a&gt;&lt;/strong&gt;. It's part of &lt;a href="https://flip3d.app" rel="noopener noreferrer"&gt;Flip3D&lt;/a&gt;, a set of 3D file tools that all run client-side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update:&lt;/strong&gt; the same flattener now powers a free &lt;a href="https://flip3d.app/3mf-viewer/" rel="noopener noreferrer"&gt;3MF viewer&lt;/a&gt; — drop any Bambu Studio, Orca or PrusaSlicer &lt;code&gt;.3mf&lt;/code&gt; to see the whole project with each object in its filament color, plus the plates, printer and slicer thumbnail. Still 100% in the browser, nothing uploaded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Follow-up:&lt;/strong&gt; &lt;a href="https://dev.to/bubudong/how-bambu-studio-stores-multi-color-painting-in-a-3mf-decoding-paintcolor-in-the-browser-20n7"&gt;How Bambu Studio stores multi-color painting in a 3MF&lt;/a&gt;: decoding the per-triangle &lt;code&gt;paint_color&lt;/code&gt; data so painted models show their real colors.&lt;/p&gt;

&lt;p&gt;It's open source if you want to read the flattener or file an issue: &lt;strong&gt;&lt;a href="https://github.com/suyfdong/flip3d" rel="noopener noreferrer"&gt;github.com/suyfdong/flip3d&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you've hit the "PrusaSlicer won't open my Bambu file" wall, that's why — and now you know exactly what's happening under the zip.&lt;/p&gt;

</description>
      <category>3dprinting</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I built free loan payoff calculators to help people save on interest</title>
      <dc:creator>Bubu Dong</dc:creator>
      <pubDate>Fri, 13 Feb 2026 02:45:09 +0000</pubDate>
      <link>https://dev.to/bubudong/i-built-free-loan-payoff-calculators-to-help-people-save-on-interest-3947</link>
      <guid>https://dev.to/bubudong/i-built-free-loan-payoff-calculators-to-help-people-save-on-interest-3947</guid>
      <description>&lt;p&gt;I recently shipped a side project: &lt;a href="https://payoffcalculators.org" rel="noopener noreferrer"&gt;PayoffCalculators.org&lt;/a&gt; — a set of free online loan payoff calculators.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;p&gt;Most mortgage calculators only show you the monthly payment. They don't answer the real question: &lt;strong&gt;how much can I save by paying extra?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I wanted a simple tool where you plug in your loan details, add an extra monthly payment, and instantly see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How many months you cut off your loan&lt;/li&gt;
&lt;li&gt;How much interest you save&lt;/li&gt;
&lt;li&gt;A full amortization schedule&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's included
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://payoffcalculators.org/mortgage-payoff-calculator" rel="noopener noreferrer"&gt;Mortgage Payoff Calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payoffcalculators.org/auto-loan-payoff-calculator" rel="noopener noreferrer"&gt;Auto Loan Payoff Calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payoffcalculators.org/loan-payoff-calculator" rel="noopener noreferrer"&gt;Loan Payoff Calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payoffcalculators.org/rv-loan-payoff-calculator" rel="noopener noreferrer"&gt;RV Loan Calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payoffcalculators.org/motorcycle-loan-payoff-calculator" rel="noopener noreferrer"&gt;Motorcycle Loan Calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://payoffcalculators.org/construction-loan-calculator" rel="noopener noreferrer"&gt;Construction Loan Calculator&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;p&gt;Next.js + Tailwind CSS, deployed on Cloudflare Workers. The calculation formulas are open source:&lt;br&gt;
&lt;a href="https://github.com/suyfdong/loan-payoff-formulas" rel="noopener noreferrer"&gt;loan-payoff-formulas&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;No signup, no ads, no tracking. Just math.&lt;/p&gt;

&lt;p&gt;Would love any feedback!&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>opensource</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
