Bake joined-node rotations so bounding boxes are tight
Verifying the GLBs in three.js (not just Blender) showed four assets reporting inflated bounds: tramp_01 came back 3.29 x 1.27 m against a true 2.96 x 0.78. Box3.setFromObject expands each mesh's LOCAL box by the world matrix, so a node carrying a rotation over-reports — the same trap Blender's obj.bound_box sets, and what three uses for frustum culling. Joined nodes inherited parts[0]'s rotation, which for an arc is half a segment step off-axis. Applying rotation at join time makes every local box axis-aligned, so the default Box3 path is now correct for consumers and culling is tight. World geometry is unchanged; the exported dims are identical. Adds tools/assetcheck/, the three.js harness that caught this. It's the only check that can: a Blender round-trip exports Z-up->Y-up and imports Y-up->Z-up, so a broken export_yup flips back and passes green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
0daeb1f2c3
commit
181dc82f1f
@ -15,5 +15,7 @@ Format: `[lane letter] YYYY-MM-DD — note`
|
||||
[E] 2026-07-16 — contract, garden_bed: all 3 damage states ship in ONE glb as sibling nodes `plants_full` / `plants_tattered` / `plants_dead` (full visible, rest hide_render). Lane A: toggle .visible, don't reload — instant swap, no pop-in. Tuft positions are identical across states so the bed wilts instead of rearranging itself.
|
||||
[E] 2026-07-16 — debris in web/world/models/debris/, copied verbatim from the library and scale-checked: BlueCrate_v2 0.36×0.36×0.29, BlackTub_v2 + WhiteTub_v2 0.36×0.54×0.20, WoodenBin_v2 0.35×0.36×0.31 m — all plausible real-world sizes. Plus tramp_01_v1.glb (2.96×2.96×0.78, mass_hint 45) because every Australian storm produces one airborne trampoline. NOTE: §0's *_v1.glb rule beats §5-E's "tramp_01.glb" spelling — Lane C, glob the dir rather than hardcoding names.
|
||||
[E] 2026-07-16 — grass is a texture, not geometry (§5-E item 9): web/world/models/textures/grass_atlas.png, 512², 2×2 tufts, alpha. Lane A instances billboards from it.
|
||||
[E] 2026-07-16 — ⚠️ FOR LANE A + LANE C, bounding boxes: `THREE.Box3.setFromObject(obj)` expands each mesh's LOCAL box by the world matrix, so a node carrying a rotation reports an inflated box — and that's what three uses for frustum culling. Blender's obj.bound_box has the identical trap (it cost me an hour of chasing phantom "failures"). Fixed at source: join_group() now bakes rotation into the vertices, so every node's local box is axis-aligned and tight. Before the fix three reported tramp_01 as 3.29×1.27 m vs its true 2.96×0.78. You can use the default Box3 safely now — but if you ever measure geometry yourself, measure VERTICES, not bound_box corners.
|
||||
[E] 2026-07-16 — landed tools/assetcheck/ (README + assets_in_three.html): loads every GLB through three.js r175 GLTFLoader and asserts Y-up, node survival, and that branch_anchor_01 lands at a sane +Y world position. This exists because the Blender round-trip CANNOT catch an axis bug — it exports Z-up→Y-up and imports Y-up→Z-up, so a broken export_yup flips back and passes. Currently green: 16/16 + anchors, `branch_anchor_01` at (-0.96, 3.64, -1.46). Lane A: once server.py + vendor/ land, this wants to live next to selftest.html — happy for you to fold it in, it reads expectations from asset_report.json so it stays in sync by itself.
|
||||
[E] 2026-07-16 — added a root .gitignore (thumbs/, *.blend, *.fbx per §0). Unowned file — Lane A, fold it into yours if you'd rather own it.
|
||||
[E] 2026-07-16 — open q for Lane A: does house_yardside at 9.20 m wide sit right on the north edge of a 30×20 m yard, or do you want a wider/second variant? Cheap to re-cut — it's one function in the script.
|
||||
|
||||
50
tools/assetcheck/README.md
Normal file
50
tools/assetcheck/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# assets_in_three.html — verify the GLBs in the real consumer
|
||||
|
||||
`build_yard_assets.py` already re-imports every GLB into Blender and asserts
|
||||
dims, tri budget, and node names. That is necessary but **structurally cannot
|
||||
catch an axis error**: Blender exports Z-up→Y-up and imports Y-up→Z-up, so a
|
||||
broken `export_yup` flips back on the way in and round-trips green. Only
|
||||
something that reads glTF natively can prove the file is right.
|
||||
|
||||
This page is that check. It loads each GLB with three.js r175's `GLTFLoader` and
|
||||
asserts:
|
||||
|
||||
- **Y-up**: a Blender asset measuring `(dx, dy, dz)` must arrive as `(dx, dz, dy)`.
|
||||
- **node survival**: `branch_anchor_*`, `fascia_anchor_*`, `plants_*` etc. still
|
||||
exist after the export — glTF has no "empty", anchors arrive as bare
|
||||
`Object3D`, and exporters have been known to prune childless nodes.
|
||||
- **anchors are usable**: `branch_anchor_01` resolves to a sane world position
|
||||
with height on +Y.
|
||||
|
||||
Expectations are read from `tools/blender/asset_report.json`, so this stays in
|
||||
sync with the factory automatically.
|
||||
|
||||
## Why it uses the default (non-precise) Box3
|
||||
|
||||
`THREE.Box3.setFromObject(obj)` expands each mesh's **local** bounding box by the
|
||||
world matrix, so a node carrying a rotation reports an inflated box. That is the
|
||||
same trap Blender's `obj.bound_box` sets, and it is what three uses for frustum
|
||||
culling. `join_group()` therefore bakes rotation into the vertices so the local
|
||||
box is axis-aligned and tight. Passing `precise: true` here would hide exactly
|
||||
the regression this page exists to catch — so don't.
|
||||
|
||||
Before that fix, three reported `tramp_01` as 3.29 × 1.27 m against a true
|
||||
2.96 × 0.78 m.
|
||||
|
||||
## Running it
|
||||
|
||||
Needs three.js on `/world/vendor/` and the models on `/models/`. Once Lane A's
|
||||
`server.py` lands, serve `web/` and this can move next to `selftest.html`
|
||||
(Lane A: happy to fold it in — see THREADS).
|
||||
|
||||
Until then, the standalone recipe:
|
||||
|
||||
```sh
|
||||
D=$(mktemp -d) && mkdir -p "$D/world"
|
||||
ln -s ~/Documents/90sDJsim/web/world/vendor "$D/world/vendor"
|
||||
ln -s "$PWD/web/world/models" "$D/models"
|
||||
ln -s "$PWD/tools/blender/asset_report.json" "$D/asset_report.json"
|
||||
cp tools/assetcheck/assets_in_three.html "$D/index.html"
|
||||
python3 -m http.server 8805 --directory "$D"
|
||||
# open http://127.0.0.1:8805 — look for "SUMMARY: ALL PASS IN THREE.JS"
|
||||
```
|
||||
82
tools/assetcheck/assets_in_three.html
Normal file
82
tools/assetcheck/assets_in_three.html
Normal file
@ -0,0 +1,82 @@
|
||||
<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Lane E — GLB check in the real consumer (three.js r175)</title>
|
||||
<style>
|
||||
body { background:#14161a; color:#dfe3e8; font:13px/1.5 ui-monospace,Menlo,monospace; padding:16px; }
|
||||
.pass { color:#7fd67f; } .fail { color:#ff6b6b; } h1 { font-size:15px; color:#9fb4c7; }
|
||||
</style>
|
||||
<h1>GLB verification — loaded by three.js GLTFLoader, not Blender</h1>
|
||||
<pre id="out">loading…</pre>
|
||||
|
||||
<script type="importmap">
|
||||
{ "imports": { "three": "/world/vendor/three.module.js",
|
||||
"three/addons/": "/world/vendor/addons/" } }
|
||||
</script>
|
||||
<script type="module">
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
|
||||
const out = document.getElementById('out');
|
||||
const log = [];
|
||||
const say = (s, cls) => {
|
||||
log.push(s);
|
||||
out.innerHTML += `<span class="${cls || ''}">${s}</span>\n`;
|
||||
console.log(s);
|
||||
};
|
||||
|
||||
const report = await (await fetch('/asset_report.json')).json();
|
||||
const loader = new GLTFLoader();
|
||||
let fails = 0;
|
||||
|
||||
say(`three.js r${THREE.REVISION} | ${report.assets.length} assets\n`);
|
||||
|
||||
for (const a of report.assets) {
|
||||
let gltf = null;
|
||||
for (const dir of ['/models/', '/models/debris/']) {
|
||||
try { gltf = await loader.loadAsync(`${dir}${a.name}_v1.glb`); break; } catch (e) {}
|
||||
}
|
||||
if (!gltf) { say(`[FAIL] ${a.name.padEnd(16)} could not load`, 'fail'); fails++; continue; }
|
||||
|
||||
const size = new THREE.Vector3();
|
||||
new THREE.Box3().setFromObject(gltf.scene).getSize(size);
|
||||
|
||||
// The whole point of this page. Blender is Z-up, glTF is Y-up, so a Blender
|
||||
// asset measuring (dx, dy, dz) MUST arrive here as (dx, dz, dy). A Blender
|
||||
// re-import can never catch a broken export_yup — it just flips it back.
|
||||
const [bx, by, bz] = a.dims;
|
||||
const exp = [bx, bz, by];
|
||||
const got = [size.x, size.y, size.z];
|
||||
const axisOk = got.every((v, i) => Math.abs(v - exp[i]) < 0.02);
|
||||
|
||||
const names = [];
|
||||
gltf.scene.traverse(o => names.push(o.name));
|
||||
const missing = a.nodes.filter(n => !names.includes(n));
|
||||
|
||||
const ok = axisOk && missing.length === 0;
|
||||
if (!ok) fails++;
|
||||
say(`[${ok ? 'PASS' : 'FAIL'}] ${a.name.padEnd(16)} ` +
|
||||
`${got.map(v => v.toFixed(2)).join(' x ')} m (Y-up)`, ok ? 'pass' : 'fail');
|
||||
if (!axisOk) say(` axis/scale: expected ${exp.map(v => v.toFixed(2)).join(' x ')}`, 'fail');
|
||||
if (missing.length) say(` nodes lost in three.js: ${missing.join(', ')}`, 'fail');
|
||||
}
|
||||
|
||||
// Empties are the risky part: glTF has no "empty", they arrive as bare Object3D
|
||||
// nodes, and exporters have been known to prune childless ones. Anchors ARE the
|
||||
// contract, so prove one survives with a usable world position.
|
||||
const t = await loader.loadAsync('/models/tree_gum_01_v1.glb');
|
||||
const anchor = t.scene.getObjectByName('branch_anchor_01');
|
||||
if (anchor) {
|
||||
t.scene.updateWorldMatrix(true, true);
|
||||
const p = new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld);
|
||||
const upright = p.y > 1.0 && p.y < 6.0;
|
||||
if (!upright) fails++;
|
||||
say(`\n[${upright ? 'PASS' : 'FAIL'}] branch_anchor_01 world pos ` +
|
||||
`(${p.x.toFixed(2)}, ${p.y.toFixed(2)}, ${p.z.toFixed(2)}) — ` +
|
||||
`${upright ? 'height is on +Y, anchors are usable' : 'height is NOT on +Y!'}`,
|
||||
upright ? 'pass' : 'fail');
|
||||
} else { fails++; say('\n[FAIL] branch_anchor_01 missing entirely', 'fail'); }
|
||||
|
||||
say(`\nSUMMARY: ${fails === 0 ? 'ALL PASS IN THREE.JS' : fails + ' FAILURES'}`,
|
||||
fails === 0 ? 'pass' : 'fail');
|
||||
window.__done = true; window.__fails = fails;
|
||||
</script>
|
||||
@ -280,6 +280,17 @@ def join_group(objs, name, parent=None):
|
||||
print(f" ! join failed for {name}: {e}")
|
||||
res = _active()
|
||||
res.name = name
|
||||
# Bake the rotation objs[0] contributed into the vertices, so the node's
|
||||
# LOCAL box is world-axis-aligned and therefore tight.
|
||||
#
|
||||
# This is not cosmetic. THREE.Box3.setFromObject() (and Blender's
|
||||
# obj.bound_box, and three's frustum culling) expand the LOCAL bounding box
|
||||
# by the world matrix. A joined node inherits objs[0]'s rotation — for an
|
||||
# arc, that's half a segment step off-axis — so every consumer computing
|
||||
# bounds the normal way would over-report these assets by ~11% and cull
|
||||
# them late. Verified against three.js r175: without this, Box3 reports
|
||||
# tramp_01 as 3.29 x 1.27 instead of its true 2.96 x 0.78.
|
||||
_apply_transform(res, rotation=True, scale=True)
|
||||
if parent is not None:
|
||||
res.parent = parent
|
||||
return res
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in New Issue
Block a user