Compare commits

...

4 Commits

Author SHA1 Message Date
m3ultra
181dc82f1f 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>
2026-07-16 21:38:32 +10:00
m3ultra
0daeb1f2c3 Log Lane E landing and PLAN3D asset-path corrections
PLAN3D §2's inventory was verified against the M1 Ultra, but we build on the
M3 Ultra, where several of those libraries are absent or moved. Records the
real paths and flags the gaps that block Lanes A and D, plus the node/anchor
contracts other lanes consume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:32:42 +10:00
m3ultra
7fc57934eb Add generated yard, hardware and debris GLBs
16 generated assets plus the grass billboard atlas and the four debris models
copied verbatim from the 3D-STORE library (copies rule, §0). All meter-scale,
Y-up, and far under the 15k tri budget — garden_bed is heaviest at 2,580.

Regeneration is byte-deterministic: two consecutive runs produce identical
files, so re-running the factory causes no churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:32:42 +10:00
m3ultra
2757b9e91f Add deterministic Blender yard-asset factory
One script regenerates every nature/hardware asset in PLAN3D §5-E, following
the house idiom from 3D-STORE/racks_to_glb.py: reset per asset, build under a
root empty at the origin, join by group, stamp props, export Y-up GLB.

Groups are joined per sway-unit rather than per-asset, so trees keep trunk and
canopy_* as separate nodes for Lane A to animate. Paths resolve from __file__
instead of a hardcoded home dir, since the library lives elsewhere on this box.

Verification re-imports each exported GLB from disk and asserts dims, tri
budget, and node-name survival, then renders it against the 1.7 m ref capsule.
Checking the file rather than the in-memory scene is what makes it a real test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:32:42 +10:00
28 changed files with 1895 additions and 0 deletions

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Per-asset verification renders — regenerable, and 3 MB of churn.
# The tiled contact_sheet.png IS committed; it's the acceptance evidence.
tools/blender/thumbs/
# PLAN3D §0: raw sources stay in the shared libraries, runtime asset is .glb
*.blend
*.blend1
*.fbx
.DS_Store
__pycache__/

View File

@ -4,3 +4,18 @@ One line per landed feature, contract change, or open question.
Format: `[lane letter] YYYY-MM-DD — note`
[.] 2026-07-16 — repo seeded: DESIGN.md (canon), PLAN3D.md (build plan), prototype/ (2D reference, do not modify)
[E] 2026-07-16 — ⚠️ PLAN3D §2's asset paths do not exist on this box. We are on m3ultra.local (Apple M3 Ultra); the plan was written against the M1 Ultra (johnking@100.91.239.7), so its "verified" inventory is that box's, not ours. Corrections below — read before you go hunting for a library.
[E] 2026-07-16 — ✅ present here: 90sDJsim (~/Documents/90sDJsim/ — vendored three r175, web/world/models/peds/ full fleet, DEVMANUAL.md). 3D-STORE IS here but at ~/Documents/Destroyulater/3D-STORE/ (NOT ~/Documents/3D-STORE) — has build_booth_room_v23.py, racks_to_glb.py, clean_glbs/. Blender 5.1.2.
[E] 2026-07-16 — ❌ MISSING here entirely, under any name: ~/Documents/character_kit/, ~/Documents/FBX/, ~/Documents/3D=models/, ~/Documents/mixamo-fetch/ (there is an unrelated ~/Documents/mixamo_upload/). Impact: Lane D's whole anim pipeline (character_kit rigs + FBX clips + mixamo-fetch wishlist) has no source on this machine, and Lane A's `3D=models/street-furniture/building_shell_01.glb` does not exist — use Lane E's house_yardside_v1.glb instead. Either those libs live only on the M1 Ultra and need syncing over Tailscale, or the inventory was aspirational. Flagging, not fixing — not my files (§0).
[E] 2026-07-16 — landed tools/blender/build_yard_assets.py: ONE deterministic script → 16 GLBs + grass atlas. Run `blender -b -P tools/blender/build_yard_assets.py` (flags: --only <name> / --no-verify / --no-debris). Proven, not asserted: 17/17 output files byte-identical across two runs; every GLB re-imported from disk and checked for dims-in-range, tri budget, and node-name survival; contact_sheet.png renders each against the 1.7 m ref capsule. Heaviest asset is garden_bed at 2,580 tris — all far under the 15 k budget. Machine-readable manifest: tools/blender/asset_report.json.
[E] 2026-07-16 — contract, trees: `trunk` (trunk+branches, rigid) and `canopy_01..03` are SEPARATE nodes — Lane A, sway the canopies only. `branch_anchor_01..03` are empties carrying anchor_type="tree" + rating_hint (thicker limb = higher) for world.anchors / Lane B.
[E] 2026-07-16 — contract, house_yardside: `fascia_anchor_01..03` empties carry rating_hint=0.35 and collateral="gutter", and the gutter node carries collateral_of="fascia" — DESIGN.md's "the fascia board is a lie" wired as data, so ripping it can take the gutter with it. Facade only (9.20 × 1.05 × 2.90 m), no interior.
[E] 2026-07-16 — contract, sail_post: exported VERTICAL with `rake_pivot` at the footing and `top_anchor` at the head. DESIGN.md says rake the post away from the load — that's a player decision, so rake is a runtime rotation about rake_pivot, NOT baked geometry. Post is 4.0 m. Also: shed_table has `pickup_anchor`, ladder_01 has `ladder_base`/`ladder_top`, gate has `hinge_axis`.
[E] 2026-07-16 — contract, hardware: shackle/carabiner/turnbuckle each keep their failure part as its own node — `pin` (unscrews then shears), `gate` (flutters open), `body` (thread strips) — with failure_mode stamped as a custom prop, so a break anim moves just that piece. They read at 1 m; see contact sheet.
[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.

View 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"
```

View 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>

View File

@ -0,0 +1,328 @@
{
"blender": "5.1.2",
"assets": [
{
"name": "ref_capsule",
"dims": [
0.4,
0.4,
1.7
],
"tris": 220,
"nodes": [
"head_height",
"ref_capsule",
"ref_capsule_mesh"
],
"status": "PASS",
"problems": []
},
{
"name": "tree_gum_01",
"dims": [
4.5522,
4.956,
7.9702
],
"tris": 396,
"nodes": [
"branch_anchor_01",
"branch_anchor_02",
"branch_anchor_03",
"canopy_01",
"canopy_02",
"canopy_03",
"tree_gum_01",
"trunk"
],
"status": "PASS",
"problems": []
},
{
"name": "tree_gum_02",
"dims": [
3.8871,
2.7787,
5.4972
],
"tris": 288,
"nodes": [
"branch_anchor_01",
"branch_anchor_02",
"canopy_01",
"canopy_02",
"tree_gum_02",
"trunk"
],
"status": "PASS",
"problems": []
},
{
"name": "fence_post",
"dims": [
0.13,
0.13,
2.03
],
"tris": 24,
"nodes": [
"fence_post",
"post"
],
"status": "PASS",
"problems": []
},
{
"name": "fence_panel",
"dims": [
2.4,
0.054,
1.8194
],
"tris": 324,
"nodes": [
"fence_panel",
"palings",
"rails"
],
"status": "PASS",
"problems": []
},
{
"name": "gate",
"dims": [
1.045,
0.0615,
1.75
],
"tris": 220,
"nodes": [
"gate",
"gate_frame",
"gate_palings",
"hinge_axis",
"hinges"
],
"status": "PASS",
"problems": []
},
{
"name": "house_yardside",
"dims": [
9.2,
1.0547,
2.9
],
"tris": 200,
"nodes": [
"door",
"fascia",
"fascia_anchor_01",
"fascia_anchor_02",
"fascia_anchor_03",
"gutter",
"house_yardside",
"roof",
"wall",
"window"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_01",
"dims": [
2.58,
1.9708,
2.2224
],
"tris": 96,
"nodes": [
"door_anchor",
"doors",
"roof",
"shed_01",
"shell"
],
"status": "PASS",
"problems": []
},
{
"name": "shed_table",
"dims": [
1.6,
0.6,
0.9
],
"tris": 72,
"nodes": [
"pickup_anchor",
"shed_table",
"table_frame",
"table_top"
],
"status": "PASS",
"problems": []
},
{
"name": "garden_bed",
"dims": [
3.0,
1.2,
0.8609
],
"tris": 2580,
"nodes": [
"bed",
"garden_bed",
"plants_dead",
"plants_full",
"plants_tattered",
"soil"
],
"status": "PASS",
"problems": []
},
{
"name": "sail_post",
"dims": [
0.507,
0.52,
4.0327
],
"tris": 528,
"nodes": [
"footing",
"pad_eye",
"post",
"rake_pivot",
"sail_post",
"top_anchor"
],
"status": "PASS",
"problems": []
},
{
"name": "ladder_01",
"dims": [
0.455,
0.075,
3.0
],
"tris": 276,
"nodes": [
"ladder",
"ladder_01",
"ladder_base",
"ladder_top"
],
"status": "PASS",
"problems": []
},
{
"name": "shackle",
"dims": [
0.0569,
0.019,
0.0744
],
"tris": 560,
"nodes": [
"bow",
"pin",
"shackle"
],
"status": "PASS",
"problems": []
},
{
"name": "carabiner",
"dims": [
0.049,
0.009,
0.1027
],
"tris": 476,
"nodes": [
"body",
"carabiner",
"gate"
],
"status": "PASS",
"problems": []
},
{
"name": "turnbuckle",
"dims": [
0.0292,
0.0341,
0.1955
],
"tris": 728,
"nodes": [
"body",
"eye_a",
"eye_b",
"turnbuckle"
],
"status": "PASS",
"problems": []
},
{
"name": "tramp_01",
"dims": [
2.9555,
2.9555,
0.78
],
"tris": 976,
"nodes": [
"legs",
"mat",
"pad",
"rim",
"tramp_01"
],
"status": "PASS",
"problems": []
}
],
"debris": [
{
"file": "BlueCrate_v2.glb",
"dims": [
0.36,
0.36,
0.29
],
"sane": true
},
{
"file": "BlackTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WhiteTub_v2.glb",
"dims": [
0.36,
0.54,
0.2
],
"sane": true
},
{
"file": "WoodenBin_v2.glb",
"dims": [
0.35,
0.36,
0.31
],
"sane": true
}
]
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

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.

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.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.