The operator does not model in Blender and was not going to learn for a cat. There was a fallback: helenFigure.ts builds a tortoiseshell figurine out of tapered tubes and a painted patch map, and the court still draws it if the real mesh fails to load. It looks like a nice toy. It does not look like Helen, and the first of the court's twelve quality checks is "It is Helen. Coat patches, blaze, and face come from real photos, not a generic 3D cat." Only a generator working from her photograph could pass that, and one did, with help; the help is most of what follows.
| Stage | What happens |
|---|---|
| Photograph | One clean, front-on, well-lit shot of Helen sitting. Several mediocre ones were tried first; the one that worked is the only one in the pipeline. |
| Tripo, task 1 | The photo becomes four synthesised views: front, left, back, right. Ten credits. |
| Tripo, task 2 | The four views become a textured PBR GLB. Sixty credits, 61.4 MB. |
| glTF-Transform | Simplify to about 120 k triangles, WebP textures, meshopt geometry. 2.16 MB. |
| Fit, in three.js | Turn it, measure it, scale it, seat it, dim its materials for a candle-lit room. |
| Judge | Three angles at two widths against the twelve checks. A person scores them. |
The two Tripo tasks
Tripo's API is a task queue: upload a file, post a task, poll it, download the result. The cat now in the court was made in two tasks from one photo taken in September 2026. The earlier models (a first sitting pass, a standing pose, and the two Morse busts for the namesake gallery) used the single-image image_to_model path from the same script family.
BASES = ("https://api.tripo3d.ai/v2/openapi",) # the v3 host no longer has task endpoints
quality = {
"model_version": "v3.1-20260211",
"texture": True,
"pbr": True,
"texture_quality": "detailed",
"geometry_quality": "detailed",
"texture_alignment": "original_image", # texture the mesh from the photo, not a guessed albedo
"orientation": "align_image", # face the way the photo faces
"enable_image_autofix": True,
}
# task 1: one photo -> four synthesised views (front, left, back, right)
status, resp = request("POST", f"{base}/task", key,
json.dumps({"type": "generate_multiview_image",
"file": {"type": "png", "file_token": token}}).encode(),
"application/json")
mv = wait(base, key, resp["data"]["task_id"], "multiview", 600)
# task 2: the four views -> a PBR GLB
gen = {"type": "multiview_to_model", "original_task_id": mv_id, **quality}
status, resp = request("POST", f"{base}/task", key, json.dumps(gen).encode(), "application/json")
data = wait(base, key, resp["data"]["task_id"], "model")
url = data["output"].get("pbr_model") or data["output"].get("model")
download(url, OUT)
print("saved", OUT, OUT.stat().st_size, "credits", data.get("consumed_credit"))
The multiview step is why the back of the cat is not a smear. A single-image model has to guess what the far side looks like. The multiview step makes that guess explicit, as four images you can open and keep, and the model step then reconstructs from all four. The script saves the views next to the model so the guess is on the record. Both tasks polled every five seconds and finished inside the ten- and fifteen-minute limits the script allows.
What came out, and what it had to fit into
The first generation of models shipped as they came out of Tripo. An August performance audit found extensionsUsed: [] on every one: raw float32 geometry, baked JPEG textures (one 1.78 MB texture inside a single GLB), 48 k to 115 k triangles, served without content-encoding. The sitting cat alone was 2.29 MB on the wire and the two busts 4.17 and 3.66 MB. Geometry density was never the problem; the encoding was.
| Model | Raw → shipped |
|---|---|
helen-sit-v2.glb (multiview, September; the court's cat) | 61.4 MB → 2.16 MB, simplified to about 120 k triangles |
helen-sit-std.glb (the court's cat through August) | 2.34 MB → 738 kB |
helen-standing.glb | 2.20 MB → 672 kB |
helen-morse-bust.glb | 3.83 MB → 781 kB |
charles-morse-bust.glb | 4.38 MB → 850 kB |
The whole dist/ | 92 MB → 11 MB, once 83 MB of reference photography and Tripo source renders stopped shipping by accident |
The glTF-Transform pass
Two scripts, one idea. compress-glb.mjs handles the four older models at 1024² textures; pack-helen-v2.mjs handles the 61 MB output with a simplify step and 2K colour. Both keep a .bak.glb of the source so the pass can be re-run with different numbers.
/**
* Encode the four live GLBs: WebP textures + Meshopt geometry.
* KTX2/ETC1S was smaller but turned Helen's fur into clay and pulled a 527 KB
* Basis transcoder onto the homepage critical path.
*/
import { NodeIO } from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dedup, meshopt, prune, resample, textureCompress, weld } from "@gltf-transform/functions";
import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
import sharp from "sharp";
await MeshoptEncoder.ready;
await MeshoptDecoder.ready;
const io = new NodeIO()
.registerExtensions(ALL_EXTENSIONS)
.registerDependencies({ "meshopt.encoder": MeshoptEncoder, "meshopt.decoder": MeshoptDecoder });
for (const job of [
{ file: "helen-sit-std.glb", max: 1024 },
{ file: "helen-standing.glb", max: 1024 },
{ file: "helen-morse-bust.glb", max: 1024 },
{ file: "charles-morse-bust.glb", max: 1024 },
]) {
const doc = await io.read(srcFor(job.file)); // .bak.glb if present, else the file itself
await doc.transform(
textureCompress({ encoder: sharp, targetFormat: "webp", resize: [job.max, job.max], quality: 88 }),
weld(),
dedup(),
prune(),
resample(),
meshopt({ encoder: MeshoptEncoder, level: "medium" }),
);
await io.write(destFor(job.file), doc);
}
const TARGET_TRIS = Number(process.argv[4] || 160000);
const TEX = Number(process.argv[5] || 2048);
const ratio = Math.min(1, TARGET_TRIS / trianglesIn(doc));
await doc.transform(
weld(),
dedup(),
ratio < 1 ? simplify({ simplifier: MeshoptSimplifier, ratio, error: 0.0008, lockBorder: false }) : (d) => d,
// Colour keeps the full size; normal + ORM at half. Fur normals at 1K read fine at court distance.
textureCompress({ encoder: sharp, targetFormat: "webp", resize: [TEX, TEX], quality: 86, slots: /baseColor/i }),
textureCompress({ encoder: sharp, targetFormat: "webp", resize: [TEX / 2, TEX / 2], quality: 80,
slots: /(normal|occlusion|metallicRoughness)/i }),
prune(),
resample(),
meshopt({ encoder: MeshoptEncoder, level: "medium" }),
);
The order matters: weld and dedup before simplify, so the simplifier sees one connected surface. Texture compression after, by slot, so the colour map keeps the detail the eye reads and the normal map does not spend bytes on detail it cannot show at court distance. Prune and resample to drop what nothing references. Meshopt last, because it wants the final vertex stream.
The compression that lost
KTX2 with ETC1S is what the guides recommend and it produced smaller files. Two things killed it here. The GPU-native texture flattened the fur into something that read as clay, on the one object in the scene whose entire job is to look like a specific soft animal. And decoding it needs the Basis transcoder, 527 kB of WASM and JS, which landed on the homepage's critical path in front of the cat. WebP decodes in the browser with nothing extra and looks like fur. Caddy then gzips model/gltf-binary, which takes roughly another ten percent off the meshopt blocks in transit.
What a generated mesh gets wrong, and what each fix cost
| What came out | What it took |
|---|---|
| Facing the wrong way. Tripo exports the busts in profile. | A yaw per bust in busts.ts (-Math.PI / 2 for both) so the face turns to +Z. The sitting cat gets rotation.y = -Math.PI / 2 in fitHelen. Trivial once known; invisible in the file. |
| Arbitrary units. No two outputs share a scale. | Measure the bounding box, recentre on it, scale so a sitting cat is 0.9 scene units tall and a standing one 1.05, then put its feet at seat height. Never trust the file's own transform. |
| A bust that is a whole person. The single-image model returned a standing figure for a portrait reference. | A keepFrac crop from the top: one bust keeps 0.92 of its height, the other only 0.34. When the value is not pinned it is picked from bounding-box slenderness (cubic heads keep almost all, standing figures crop to the shoulders). The reference photos were cropped by hand first (crop_morse_bust_refs.py). |
| The tongue. Something was wrong with it on the August sitting model. | Fixed by hand in the mesh on 2026-08-27; the repo keeps helen-sit-std.pre-tongue-fix.bak.glb next to the result. The two files differ by 2.6 kB. The face is the part people look at. |
| Materials too bright for a candle-lit room. | envMapIntensity = 0.42 on every material at load; shadows cast and received. The generator's PBR is tuned for a white studio. |
| The back of the cat. A single image tells the generator about one side. | The multiview path for the September model. Ten credits for four views is the cheapest fix in this table. |
| Too many triangles and too much texture. 61 MB. | The pack script above. One number to tune. |
| A cat that fails to load at all. | Sitting model, then standing model, then the procedural figurine. The court never shows missing-texture purple or a T-pose; that is check eleven of twelve. |
function fitHelen(model: THREE.Group, pose: "sit" | "stand", opts?: { sitHeight?: number; z?: number; seatY?: number }) {
model.traverse((obj) => {
const mesh = obj as THREE.Mesh;
if (!mesh.isMesh) return;
mesh.castShadow = true;
mesh.receiveShadow = true;
const mat = mesh.material;
if (mat && !Array.isArray(mat) && "envMapIntensity" in mat) {
(mat as THREE.MeshStandardMaterial).envMapIntensity = 0.42;
}
});
const box = new THREE.Box3().setFromObject(model);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
model.position.sub(center);
const group = new THREE.Group();
group.name = "HelenMesh";
group.add(model);
const tall = Math.max(size.y, 0.001);
const sitH = opts?.sitHeight ?? 0.9;
group.scale.setScalar((pose === "sit" ? sitH : 1.05) / tall);
const fitted = new THREE.Box3().setFromObject(group);
const z = opts?.z ?? 0.35;
if (pose === "sit") {
const seatY = opts?.seatY ?? 0.4;
group.position.set(0, seatY - fitted.min.y, z);
group.rotation.y = -Math.PI / 2;
}
...
}
export async function loadHelenMesh(opts) {
const loader = await createGltfLoader(); // GLTFLoader + MeshoptDecoder
try {
const gltf = await loader.loadAsync(helenSitUrl()); // "/models/helen-sit-v2.glb?v=mv1"
return fitHelen(gltf.scene, "sit", opts);
} catch (err) {
console.warn("[court] sitting mesh missing, trying standing", err);
try {
const gltf = await loader.loadAsync(STANDING);
return fitHelen(gltf.scene, "stand", opts);
} catch (err2) {
console.warn("[court] helen mesh missing", err2);
return null; // caller draws the procedural figurine
}
}
}
The ?v=mv1 on the URL is the cache-bust. Models are served with a seven-day cache and stale-while-revalidate; when the mesh changes the query string changes, and nobody sees last week's cat.
How it is judged
Not by a model. npm run qa renders three angles at two widths and writes qa-output/score.json against the twelve checks; a slice is not done until a screenshot fails fewer than two. The checks read like "coat patches from real photos," "materials feel heavy, plastic sheen is a fail," "empty court still looks finished." A vision model could score some of them. A person scored all of them, because the question is whether a specific cat's owner recognises her, and no benchmark measures that.
Four things to know before you try it
- Credits are Tripo's unit, not dollars. The two-task pass cost 70. What a credit costs depends on the plan, and the operator's is not recorded here, so no dollar figure is claimed.
- Every generation is different. Same photo, same parameters, different mesh next time. Keep the task JSON and the output, and treat "regenerate" as "start over," not "retry."
- The multiview path is only better if the photo is good. One clean front-on shot beat several mediocre ones.
- Meshopt needs its decoder.
MeshoptDecoderis small and ships in the court chunk. If you go KTX2 you also ship Basis; count it.
Where the mesh goes next is the court build page; what the rest of the models on this project do is the AI page; the court without the code is at /court/.
The story above runs once through. The longer section splits it: the plain-language entry, all eight Tripo tasks with settings and credits, the glTF-Transform pass and the tongue repaint, the still pictures a model drew, and the credit ledger.