Get the three.js bounding box and triangle count of a mesh or GLB model, and check it is watertight

How to get a three.js object's bounding box, size and triangle count, the one-line way with Box3 and from a coding agent with the paprika3d plugin, which adds whether each mesh is closed (watertight), its volume and genus, a four-side picture with its size, and a GLB file's triangles read from the file without loading it.

The one-line answer

three.js answers the size question itself. new THREE.Box3().setFromObject(object) walks the object and its children and returns the box around them in world space, axis-aligned; getSize and getCenter give its dimensions and middle. Triangles are a count on each geometry: the index count divided by three when the geometry has an index, the position count divided by three when it does not. Both run in the browser console or in the page, and neither needs any tool.

Three questions are left after that. A rotated object's box from setFromObject can be larger than the object, unless you pass true as its second argument. A count of triangles says nothing about whether the surface is closed, which 3D printing, boolean operations and volume all need. And counting a GLB model's triangles this way means loading it into a page first.

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome. Its eval tool runs code in the page with THREE, scene, find(name) and stats(target) in scope; stats reports each mesh's vertices, triangles, world size, and whether its surface is closed, with the volume and genus of a closed one. look with target pictures one object from four sides with its size, and model_audit reads a .glb, .gltf or USD file's triangles, materials and textures from the file itself.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

A page with four kinds of mesh

A group named parts holds a closed box, a pipe with open ends lying on its side, a ring turned 45 degrees, and a box with one triangle missing, built the way a broken STL export arrives: unindexed, with a hole where a face should be.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Parts</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js"}}</script>
<script type="module">
import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e2228);
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(1.5, 4, 11);
camera.lookAt(1.5, 0, 0);
scene.add(new THREE.HemisphereLight(0xffffff, 0x333344, 1.5));
const sun = new THREE.DirectionalLight(0xffffff, 1.5);
sun.position.set(3, 6, 4);
scene.add(sun);

const mat = (color) => new THREE.MeshStandardMaterial({ color, side: THREE.DoubleSide });
const parts = new THREE.Group();
parts.name = 'parts';

const crate = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), mat(0xb08850));
crate.name = 'crate';
crate.position.x = -3;

// A pipe with open ends, lying on its side.
const pipe = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 2, 24, 1, true), mat(0x8090a0));
pipe.name = 'pipe';
pipe.rotation.z = Math.PI / 2;

// A ring turned 45 degrees about y: one hole through it.
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.6, 0.2, 16, 48), mat(0xd0a040));
ring.name = 'ring';
ring.position.x = 3;
ring.rotation.y = Math.PI / 4;

// A box with one triangle missing, as a broken STL export arrives: unindexed, one face half open.
const broken = new THREE.BoxGeometry(1, 1, 1).toNonIndexed();
broken.setAttribute('position', new THREE.Float32BufferAttribute(broken.attributes.position.array.slice(0, -9), 3));
broken.computeVertexNormals();
const shell = new THREE.Mesh(broken, mat(0x60a070));
shell.name = 'shell';
shell.position.x = 6;

parts.add(crate, pipe, ring, shell);
scene.add(parts);

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
renderer.setAnimationLoop(() => renderer.render(scene, camera));
</script>
</body></html>

The tool calls, in order

These replies were captured on 2026-10-28 on a Linux server with no GPU, where Chrome draws in software (SwiftShader); sizes and counts do not depend on the renderer. Run folder paths are shortened to <project>.

1. Open the page.

open {"url": "index.html", "cwd": "<project>"}

Opened http://127.0.0.1:42917/index.html in 0.2 s, paused at frame 0.
GPU: ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver). A software renderer (no GPU): frame times here say nothing of a real GPU's.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["parts"]}
Next: `look` to see it, then `interact` for each interaction the request names.

Run folder: <project>/paprika3d/runs/2026-10-28T05-50-17-203Z

(The reply's lines about the geo.js and fx.js helpers it writes into the project are left out here.)

2. The box around a group, with Box3. This is the line to use in your own code:

eval {"code": "const box = new THREE.Box3().setFromObject(find('parts')); return { size: box.getSize(new THREE.Vector3()).toArray(), center: box.getCenter(new THREE.Vector3()).toArray(), min: box.min.toArray(), max: box.max.toArray() }"}

{"size":[10,1.6,1.414],"center":[1.5,0,0],"min":[-3.5,-0.8,-0.707],"max":[6.5,0.8,0.707]}

The group spans 10 units in x. Its depth of 1.414 comes from the turned ring, and is more than the ring really takes up.

3. Loose and precise boxes. By default setFromObject turns each geometry's own box into world space and boxes that, which grows for a rotated mesh. With true as the second argument it boxes the vertices themselves:

eval {"code": "const ring = find('ring'); return { loose: new THREE.Box3().setFromObject(ring).getSize(new THREE.Vector3()).toArray(), precise: new THREE.Box3().setFromObject(ring, true).getSize(new THREE.Vector3()).toArray() }"}

{"loose":[1.414,1.6,1.414],"precise":[1.249,1.6,1.249]}

The precise box is slower, since it visits every vertex, and is the true answer for a turned or tilted object.

4. Count triangles and vertices by hand.

eval {"code": "const out = {}; find('parts').traverse(o => { if (o.isMesh) { const g = o.geometry; out[o.name] = { vertices: g.attributes.position.count, triangles: (g.index ? g.index.count : g.attributes.position.count) / 3 }; } }); return out"}

{"crate":{"vertices":24,"triangles":12},"pipe":{"vertices":50,"triangles":48},"ring":{"vertices":833,"triangles":1536},"shell":{"vertices":33,"triangles":11}}

The crate has 24 vertices for 8 corners: three.js primitives split vertices at each hard edge, so each face has its own normals and texture coordinates. A vertex count from a geometry is a count of what the GPU receives, not of the shape's corners.

5. Ask which meshes are closed. stats welds vertices that share a position before it looks at the edges, so those split seams do not count as holes:

eval {"code": "return stats('parts')"}

total  4 meshes  940 v  1,607 tri
crate  24 v  12 tri  closed  vol 1  genus 0  size 1×1×1  [position, normal, uv]
pipe  50 v  48 tri  open (48 edges)  size 2×0.6×0.6  [position, normal, uv]
ring  833 v  1,536 tri  closed  vol 0.46  genus 1  size 1.414×1.6×1.414  [position, normal, uv]
shell  33 v  11 tri  open (3 edges)  size 1×1×1  [position, normal, uv]

6. Picture one object from every side. look with target frames the object alone from the front, right, top and a three-quarter angle, and states its size and centre:

look {"target": "ring"}

Picture: <project>/paprika3d/runs/2026-10-28T05-50-17-203Z/look-1-views.png (front, right, top, three-quarter)
ring: size 1.414 × 1.6 × 1.414, centre [3, 0, 0]

7. A GLB file's triangles, without loading it. model_audit reads the file's JSON and image headers and needs no page. Here it reads a duck model (CC BY 4.0, "Duck" by AIUM2 on Sketchfab, optimized for the web by paprika3d) in the project's models/ folder:

model_audit {"cwd": "<project>", "path": "models/duck.glb"}

models/duck.glb · 384 KB · 19,148 triangles · 1 material · textures: 1024×1024 webp, 1024×1024 webp, 1024×1024 webp, 1024×1024 webp, 1024×1024 webp · Draco no, meshopt yes, KTX2 no
site: passes

The file is compressed with meshopt; model_audit reads Draco and meshopt files the same way, with no decoder. Without path it reports every model file in the project. Vertex counts, sizes and closed edges need the model loaded in a page, where stats gives them.

What paprika3d does not do

Pitfalls

Without paprika3d

Box3.setFromObject, the index and position counts, and renderer.info.render.triangles (the triangles of the last frame drawn) need nothing beyond three.js. For watertightness, weld the vertices with mergeVertices from three/addons/utils/BufferGeometryUtils.js (after deleting the normal and uv attributes, so positions alone decide), then count how many triangles share each edge: every edge used exactly twice means a closed surface. For a model file, the glTF Transform command line's inspect lists each mesh's vertices and primitives, and Blender's statistics overlay shows the triangles of an imported model.