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]
closedmeans every edge is shared by two triangles: the surface is watertight.open (3 edges)on the shell is the missing triangle's three edges; on the pipe, the 48 open edges are its two rims of 24 segments each.volis the enclosed volume, given for a closed surface and left out for an open one. The crate's is 1, as a unit cube's should be.genusis the number of holes through the shape: 0 for the crate, 1 for the ring. A shape meant to be solid with a genus above 0 has a tunnel through it.sizeis the world box of each mesh's own box, like the looseBox3above, so the turned ring reads 1.414 across, not 1.249.
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
- It does not repair an open mesh.
statsnames how many edges are open; closing them is a modelling step. statsandlooktargetgive the loose box for a rotated mesh. UsesetFromObject(object, true)inevalfor the tight one.statsignores geometry groups and draw ranges, and measures skinned and morphed meshes in their rest pose.
Pitfalls
- Update matrices before measuring.
setFromObjectreads world matrices; for an object you just moved outside the render loop, callobject.updateWorldMatrix(true, true)first. - Know the units. A model exported in centimetres measures 100 times too large in a scene in metres, and the box is the quick check.
- Non-indexed is not broken. An STL loads unindexed, with every triangle's corners separate; a check that does not weld by position reports every edge as open.
- Count after compression is undone. A Draco or meshopt file's triangle count is the count after decoding; the file size says nothing about it.
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.