<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[3D Форматика Maker]]></title><description><![CDATA[3D Форматика Maker]]></description><link>https://3dformatika-maker.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>3D Форматика Maker</title><link>https://3dformatika-maker.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Mon, 31 Aug 2026 12:18:10 GMT</lastBuildDate><atom:link href="https://3dformatika-maker.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Voxelizing 3D Models in the Browser Without Freezing the UI]]></title><description><![CDATA[Interactive 3D tools feel natural in the browser until a user starts a geometry-heavy operation. Parsing a model, testing thousands of triangles against a voxel grid, filling the volume, and rebuildin]]></description><link>https://3dformatika-maker.hashnode.dev/voxelizing-3d-models-in-the-browser-without-freezing-the-ui</link><guid isPermaLink="true">https://3dformatika-maker.hashnode.dev/voxelizing-3d-models-in-the-browser-without-freezing-the-ui</guid><category><![CDATA[ThreeJS]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[3D printing]]></category><dc:creator><![CDATA[Vladislav An]]></dc:creator><pubDate>Wed, 12 Aug 2026 14:02:21 GMT</pubDate><content:encoded><![CDATA[<p>Interactive 3D tools feel natural in the browser until a user starts a geometry-heavy operation. Parsing a model, testing thousands of triangles against a voxel grid, filling the volume, and rebuilding a printable mesh can easily monopolize the main thread. The result is familiar: the viewport stops rendering, progress indicators freeze, and the browser may suggest that the page is unresponsive.</p>
<p>I ran into this problem while building a voxel editor for 3D-printing workflows. The solution was not a single optimization. It was a pipeline that treats parsing, voxelization, progress reporting, cancellation, and mesh reconstruction as separate concerns.</p>
<p>This article walks through that architecture and the trade-offs behind it.</p>
<h2>The pipeline at a glance</h2>
<p>The browser-side flow has six stages:</p>
<ol>
<li><p>Parse an STL, OBJ, or 3MF file away from the UI thread.</p>
</li>
<li><p>Flatten the loaded Three.js object hierarchy into a triangle buffer.</p>
</li>
<li><p>Transfer that buffer to a dedicated voxel worker.</p>
</li>
<li><p>Mark surface voxels and fill the interior.</p>
</li>
<li><p>Generate an optimized surface mesh.</p>
</li>
<li><p>Transfer the result back and replace the displayed object.</p>
</li>
</ol>
<p>The main thread owns the scene and the user interface. Workers own expensive geometry work. Messages between them contain typed arrays and small progress objects rather than Three.js scene objects.</p>
<p>That separation matters because <code>Object3D</code>, <code>BufferGeometry</code>, and materials are not directly transferable. A compact triangle soup is a much cleaner boundary.</p>
<h2>Parse model formats outside the main thread</h2>
<p>Model parsing is already expensive enough to cause visible stutters on larger files, especially for compressed 3MF archives. I use a model-loader worker that dynamically imports only the parser needed for the selected format:</p>
<pre><code class="language-js">async function ensureParser(extension) {
  if (extension === "stl") {
    return import("three/examples/jsm/loaders/STLLoader.js");
  }
  if (extension === "obj") {
    return import("three/examples/jsm/loaders/OBJLoader.js");
  }
  if (extension === "3mf") {
    return Promise.all([
      import("three/examples/jsm/loaders/3MFLoader.js"),
      import("three/examples/jsm/libs/fflate.module.js"),
    ]);
  }
  throw new Error("Unsupported model format");
}
</code></pre>
<p>Dynamic imports keep format-specific code out of the initial editor path. The worker parses the source, serializes the resulting object hierarchy into plain data, and transfers geometry buffers back to the main thread.</p>
<p>The UI receives progress events such as “opening file,” “applying materials,” and “preparing the preview.” These messages are not cosmetic. They prove that the event loop is still alive and give users a reason not to retry the same operation.</p>
<h2>Reduce the scene to triangles</h2>
<p>Voxelization does not need lights, materials, object names, or most scene metadata. It needs world-space triangles.</p>
<p>Before starting the voxel worker, the editor traverses the model, applies object transforms, and writes triangle coordinates into a <code>Float32Array</code>. Each triangle occupies nine consecutive values:</p>
<pre><code class="language-text">x0, y0, z0, x1, y1, z1, x2, y2, z2
</code></pre>
<p>This representation has three useful properties:</p>
<ul>
<li><p>it is independent of Three.js classes;</p>
</li>
<li><p>it is sequential and cache-friendly;</p>
</li>
<li><p>its underlying <code>ArrayBuffer</code> can be transferred without serialization overhead.</p>
</li>
</ul>
<p>The editor keeps its source geometry and transfers a copy to the worker. That avoids detaching the only copy of the triangle buffer when <code>postMessage</code> transfers ownership.</p>
<pre><code class="language-js">const trianglesForWorker = sourceTriangles.slice();

worker.postMessage(
  {
    requestId,
    resolution,
    triangles: trianglesForWorker.buffer,
  },
  [trianglesForWorker.buffer],
);
</code></pre>
<h2>Build a predictable cubic grid</h2>
<p>The editor offers three grid resolutions: 32, 64, and 128. A cubic grid keeps cell dimensions identical on every axis and makes indexing straightforward:</p>
<pre><code class="language-js">const index = x + resolution * (y + resolution * z);
</code></pre>
<p>Occupancy lives in a <code>Uint8Array(resolution ** 3)</code>. The cell size is derived from the largest model dimension, so the model retains its proportions while fitting inside the cube.</p>
<p>One small detail prevents several later problems: the grid includes an empty boundary around the source bounds. The implementation uses roughly one and a half cells of padding. Without that boundary, a surface lying exactly on the edge of the grid can block the exterior flood fill or cause asymmetric bounds when it intersects cells on both sides of a grid plane.</p>
<p>Padding is cheap compared with debugging a model that is mysteriously solid on one side and hollow on the other.</p>
<h2>Mark the surface efficiently</h2>
<p>A naive implementation would test every triangle against every voxel. That grows too quickly to be useful.</p>
<p>Instead, the worker processes one triangle at a time:</p>
<ol>
<li><p>Reject degenerate triangles.</p>
</li>
<li><p>Compute the triangle's axis-aligned bounding box.</p>
</li>
<li><p>Convert that box to a bounded range of grid coordinates.</p>
</li>
<li><p>Run a triangle-versus-box intersection test only for cells in that range.</p>
</li>
<li><p>Mark intersecting cells as surface voxels.</p>
</li>
</ol>
<p>The broad-phase bounding box removes most candidate cells before the more expensive intersection test. Progress is reported every fixed batch of triangles rather than after every triangle, which keeps message traffic low:</p>
<pre><code class="language-js">if (triangleIndex % 128 === 0) {
  postProgress("surface", triangleIndex / triangleCount);
}
</code></pre>
<p>In my pipeline, surface construction occupies the first 70% of the progress bar because it is usually the dominant stage. Progress percentages do not need to be academically precise, but they should reflect the work users actually wait for.</p>
<h2>Fill the interior with an exterior flood fill</h2>
<p>After surface marking, the grid contains a shell and unknown empty cells. The simplest robust way to classify the remaining cells is to flood-fill the exterior.</p>
<p>The worker enqueues every empty cell on the six boundary faces. It then performs a six-connected breadth-first traversal through empty neighbors. Visited cells are exterior; unvisited cells are enclosed by the surface and therefore belong to the solid volume.</p>
<pre><code class="language-js">while (head &lt; tail) {
  const cell = queue[head++];

  visit(cell.x - 1, cell.y, cell.z);
  visit(cell.x + 1, cell.y, cell.z);
  visit(cell.x, cell.y - 1, cell.z);
  visit(cell.x, cell.y + 1, cell.z);
  visit(cell.x, cell.y, cell.z - 1);
  visit(cell.x, cell.y, cell.z + 1);
}
</code></pre>
<p>An <code>Int32Array</code> works well as a fixed-size queue. There are no object allocations per cell, and the queue can never contain more entries than the occupancy grid.</p>
<p>Once the traversal finishes, exterior markers return to zero and every other cell becomes occupied. This technique assumes that the marked shell is sufficiently closed at the chosen resolution. Small gaps can disappear at a coarse resolution or leak at a fine one, so resolution remains a modeling decision rather than a simple quality slider.</p>
<h2>Reconstruct the mesh without emitting every cube</h2>
<p>Rendering one cube per occupied voxel is convenient for a prototype but produces far too many internal and duplicate faces. Even emitting only exposed unit faces creates unnecessarily large files.</p>
<p>The worker uses greedy meshing instead. For each principal axis, it builds a 2D mask where an occupied cell meets an empty cell. Adjacent mask entries with the same orientation are merged into the largest possible rectangles. Each rectangle becomes one quad.</p>
<p>This dramatically reduces the number of faces on flat voxel regions.</p>
<p>There is a topology trap, however. A large greedy quad may meet several smaller perpendicular quads along one edge. Visually they appear closed, but exporting the large edge as a single segment creates T-junctions. Some STL, OBJ, and 3MF consumers interpret those junctions as an open mesh.</p>
<p>The implementation records every endpoint that appears on each grid line. Before triangulating a quad, it splits its boundary at all matching endpoints. Simple quads still become two triangles; boundaries with additional cuts are triangulated around a center vertex. The result keeps the compact greedy surface while making adjacent faces agree on edge segmentation.</p>
<p>That extra topology pass was more valuable than chasing a slightly smaller triangle count. For 3D printing, predictable downstream geometry is the real optimization target.</p>
<h2>Return only transferable geometry</h2>
<p>The worker returns positions, normals, and indices as typed arrays:</p>
<pre><code class="language-js">self.postMessage(
  {
    type: "result",
    requestId,
    positions: positions.buffer,
    normals: normals.buffer,
    indices: indices.buffer,
    filledCount,
    quadCount,
  },
  [positions.buffer, normals.buffer, indices.buffer],
);
</code></pre>
<p>The main thread wraps those arrays in <code>BufferAttribute</code> objects and creates the replacement mesh. No large geometry payload is copied between threads.</p>
<p>Each operation gets a request ID. The UI ignores events from stale workers, which matters when users switch objects or start another rebuild before an earlier message arrives.</p>
<h2>Cancellation can be simple</h2>
<p>JavaScript does not need cooperative cancellation inside every inner voxel loop if each rebuild owns a dedicated worker. The cancel action can terminate the worker, reject the pending operation, and restore the UI state.</p>
<p>This approach has a cost: starting another rebuild creates another worker. For occasional, user-triggered geometry operations, that cost is small and the lifecycle is easy to reason about.</p>
<p>An <code>AbortSignal</code> still connects voxelization to larger editor operations. For example, saving a project may need to rebuild stale voxel models first. Cancelling the save aborts the source download or parsing step and terminates the active voxel worker.</p>
<h2>Preserve a recipe, not only the result</h2>
<p>A voxel mesh is derived data. Saving only the generated triangles makes later edits difficult because the relationship to the source model is lost.</p>
<p>The editor therefore stores a small recipe alongside the result:</p>
<pre><code class="language-js">{
  algorithm: "surface-fill-v1",
  resolution: 64,
  sourcePartIndex: 0,
}
</code></pre>
<p>It also retains a source descriptor when the original file can be downloaded again. If a user changes the resolution, the mesh becomes stale and must be rebuilt from the source before pricing or saving.</p>
<p>This distinction between source, recipe, and derived mesh makes the feature behave like an editor rather than a one-way converter.</p>
<h2>Practical lessons</h2>
<p>The most reusable lessons from this implementation are not specific to voxels:</p>
<ul>
<li><p>Move both parsing and geometry processing away from the main thread.</p>
</li>
<li><p>Cross worker boundaries with typed arrays, not framework objects.</p>
</li>
<li><p>Design progress stages around actual computational cost.</p>
</li>
<li><p>Keep cancellation semantics simple and deterministic.</p>
</li>
<li><p>Treat exported topology as a correctness requirement.</p>
</li>
<li><p>Save enough source information to rebuild derived geometry.</p>
</li>
<li><p>Offer a few deliberate quality levels instead of an unbounded resolution input.</p>
</li>
</ul>
<p>I use this pipeline in <a href="https://maker.3dformatika.com/">3D Форматика Maker</a>, where voxelization is one part of a broader set of browser tools for 3D-printing workflows. Building it reinforced a useful rule: browser-based geometry becomes much easier to manage once the UI thread is treated as a coordinator, not a compute engine.</p>
<p>If you want to explore the surrounding product, the available <a href="https://maker.3dformatika.com/editors">browser-based 3D editors</a> cover several focused modeling and preparation workflows.</p>
]]></content:encoded></item></channel></rss>