Lane E R41 (§41.1+§41.2): 46 clips and 110 fittings — and every handover number was the wrong unit

MOTION (§41.1). The clip library goes 8 -> 46: ten idles, eight browse, eight sit/lean,
eight social, six locomotion, six venue, in SIX grouped GLBs (one fetch each), 3.35 MB —
LESS than the 4.29 MB the old eight cost, via lossless dedup + int16 rotations (worst
error 0.0034 deg). All six verified skeleton-only (tris 0, meshes 0, nodes 66): ZERO DRAW,
which is what makes this round affordable. No retarget was run and none was wanted — the
bank and the peds are the same mixamorig skeleton, so retargeting would add error AND bake
the ped mesh into the clip, ending zero-draw. MIRPAMO 'make smoke' green to prove the tool,
then deliberately unused. Three seeds in the brief were duds and got substituted: the whole
*_Degree_Turn set is RIFLE-AIMING, and two 'examine' clips are 262 KB static poses, not
motion. R16 flat-body trap re-checked on all 46 (spine tilt 24 samples/clip, 0 frames >75
deg); turn_in_place auto-demoted from loopable on a 36.7 deg seam.

RULING 1 — the Bandai hazard, closed non-destructively and better than specified. 3,077 CC
BY-NC clips sat unzoned in a neutrally-named path. Renamed PER FILE (3,077/3,077) not just
the directory, because mirpamo names output <rig>@<clip>.glb — the _NC-research marker now
PROPAGATES into any retargeted GLB automatically. Nothing deleted; ultra's red/bandai
re-verified as canonical. Ledger corrected: the manifest claimed CC-BY-NC-ND, the bundled
licence text says CC BY-NC, no ND.

PROPS (§41.2). 110 assets from three libraries, published, sha1-verified, 0 validator errors
both modes. THE FINDING: every handover number was a TRIANGLE count, and triangles were
never the binding constraint — DRAW CALLS were. As handed over this cargo cost 14,140 draws
against a 162-draw margin; it ships at 117. '3dstore passes as-is' was true on tris/metres/
Draco/textures and FALSE on draws — 30 of 46 files carried up to 16 materials on one mesh
(one per record sleeve), so a 1,020-tri tub cost 16 draws; baked to COLOR_0, 288 -> 40, zero
tri drift, A/B identical. dj-gear's own manifest claimed median 6,288 tris / 18 under budget;
measured 51,528 and 12, with draws to 2,145. Pub props were NOT metre-correct (every source
unit-normalised to max dim 1.00 m) and 40x decimation was impossible as specified (loungeChair
is 94% non-manifold).

Ruling 5 vs the draw budget was a real conflict (one mixer = 4.6x a whole room); resolved by
joining SCENERY selectively while every control node keeps its object/name/pivot — verified
by parsing the SHIPPED GLB, not the tool that wrote it: PASS 4 / STATIC 10 / N/A 17 / FAIL 0.
deck_1200_rigged = 5 draws with Platter_SPIN, Arm_YAW, Fader_PITCH, Btn_STARTSTOP intact.

The R40 four-surface transmission gate FIRED ON LIVE CARGO: 5 genuinely transmissive
materials (cartridge dust-covers, a mixer meter window) that would each have doubled every
opaque draw in the room. 9 assets rejected on eyeball after decimation rather than shipping
mush. Two existing-tooling bugs fixed: normalize.py's yaw/up were silent no-ops (the jukebox
exported 0.40 m instead of 0.97 m), and footprints now measure the shipped GLB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-08-07 18:10:12 +10:00
parent f2ffe00ad3
commit 8ad446a48a
153 changed files with 9323 additions and 13 deletions

View File

@ -7,9 +7,302 @@
> lanes need from this one, newest first) and **[`pipeline/AUDIT.md`](pipeline/AUDIT.md)**
> (provenance, licence zone, measurements and method, per round). Recorded in R39 rather than
> quietly resurrected: a progress file whose last entry is 27 rounds old is a trap for the next
> reader, and the honest fix is a signpost, not a backfill. *(One exception below: the R40 tasking
> explicitly asked for its control-arm transcripts in THIS file, so §40 lives here AND is
> cross-referenced from the notes.)*
> reader, and the honest fix is a signpost, not a backfill. *(Two exceptions below: the R40 tasking
> explicitly asked for its control-arm transcripts in THIS file, and the R41 §41.2 tasking asked for
> its three import tables here, so §40 and §41 live here AND are cross-referenced from the notes.)*
## Round 41 (§41.2 — THE FITTINGS) · 2026-08-07
*Entry here by explicit R41 tasking (as R40 was). Cross-lane hand-off for C in
`docs/LANES/LANE_E_NOTES.md` §41; provenance in `pipeline/AUDIT.md`.*
**110 assets landed** from three libraries, all interior (ruling 4), all published + sha1-verified,
validator green. The round's one recurring lesson: **every handover number was a triangle count,
and triangles were never the binding constraint — draw calls were.**
| | source | shipped | rejected | tris | draws |
|---|---|---|---|---|---|
| `~/Documents/3dstore` | 46 | **40** | 6 | 18,420 | 288 → **40** |
| `not-tonight/art_incoming` | 48 | **39** | 3 (+6 `staff*` skipped) | 9,508,361 → 194,837 | 39 |
| ultra `3D=models/dj-gear` | 60 | **31** | 29 not selected | 2,284,094 → 143,340 | 13,813 → **38** |
| **total** | **154** | **110** | — | 11,810,875 → **356,597** | 14,140 → **117** |
### The finding that shaped the round: draws, not tris
The tasking said 3dstore "passes the house law AS-IS". On tris, metres, Draco and textures that is
exactly true and I re-verified all four. It is false on the number Lane C spends: **30 of the 46
files carry 316 materials on a single mesh** (one flat colour per record sleeve,
`Mat_Sleeve_<hex>`), and glTF gives each material its own primitive, which `GLTFLoader` turns into
its own `Mesh`. A 1,020-tri "Black Tub" cost **16 draw calls**. The interior margin is 162 — ten
tubs would have been the whole room.
Every one of those materials is a flat `baseColorFactor` with no texture and no UVs, so
`pipeline/merge_prims.py` bakes each primitive's colour into a `COLOR_0` attribute and emits one
white-factor material. glTF multiplies `COLOR_0` by `baseColorFactor` and both are linear, so the
result is **numerically the original colour** — verified by A/B render
(`docs/shots/laneE/r41_3dstore_merge_ab.png`) and by zero triangle drift on all 40.
The same defect, unmeasured, was in the DJ gear: joined bodies still carried 531 materials, so
"one mesh" was still 531 draws. `merge_prims.py` over that set took it **530 → 38**.
### §41.2a — `~/Documents/3dstore` · 40 shipped, 6 rejected
Re-measured with `glb_stat.py`, not quoted: 45 of 46 are ≤5k tris (median 384), metre-correct,
+Y up, no Draco, no textures. **44 of 45 are base-origin**`vinylsleeve_v5` is centred
(minY 0.160) and was re-seated. `3dmrp.glb` confirmed at **499,948 tris** + a 4096px texture.
No Blender pass: the geometry is already correct and every extra tool is a chance to move it.
Spec: `pipeline/r41_3dstore.json` (by name, ruling 3) · stager: `pipeline/r41_stage_3dstore.py`.
| id | kind | tris | draws | footprint (m) | height (m) |
|---|---|---:|---|---|---:|
| `beam_roof` | arch | 12 | 1 → 1 | 6.5 × 0.32 | 0.36 |
| `column_square` | arch | 12 | 1 → 1 | 0.22 × 0.22 | 2.6 |
| `column_wide` | arch | 12 | 1 → 1 | 0.36 × 0.46 | 2.6 |
| `door_shop` | arch | 144 | 1 → 1 | 1.7 × 0.19 | 2.3 |
| `bin_endoffunk` | bin | 972 | 16 → 1 | 0.36 × 0.34 | 0.34 |
| `chiu_bin` | bin | 996 | 16 → 1 | 0.37 × 0.43 | 0.35 |
| `chiu_bin_half` | bin | 600 | 16 → 1 | 0.24 × 0.43 | 0.35 |
| `chiu_bin_low` | bin | 912 | 16 → 1 | 0.34 × 0.43 | 0.35 |
| `crate_blue` | bin | 936 | 16 → 1 | 0.34 × 0.3 | 0.322 |
| `mos_bin_bottom` | bin | 912 | 16 → 1 | 0.34 × 0.4 | 0.34 |
| `mos_bin_lower_mid` | bin | 912 | 16 → 1 | 0.34 × 0.36 | 0.34 |
| `mos_bin_top` | bin | 912 | 16 → 1 | 0.34 × 0.2 | 0.34 |
| `mos_bin_upper_mid` | bin | 912 | 16 → 1 | 0.34 × 0.26 | 0.34 |
| `rack_bin_wooden` | bin | 912 | 16 → 1 | 0.34 × 0.4 | 0.338 |
| `tub_black` | bin | 1020 | 16 → 1 | 0.36 × 0.53 | 0.229 |
| `tub_black_long` | bin | 1536 | 16 → 1 | 0.53 × 0.36 | 0.229 |
| `tub_white` | bin | 972 | 16 → 1 | 0.34 × 0.53 | 0.229 |
| `tub_white_long` | bin | 1536 | 16 → 1 | 0.53 × 0.36 | 0.229 |
| `counter_station` | counter | 192 | 1 → 1 | 0.88 × 0.5 | 0.87 |
| `counter_station_side` | counter | 192 | 1 → 1 | 0.5 × 0.88 | 0.87 |
| `vinyl_sleeve` | prop | 180 | 4 → 1 | 0.32 × 0.01 | 0.32 |
| `window_booth` | prop | 144 | 1 → 1 | 1.85 × 0.09 | 0.72 |
| `bin_big_4x4` | rack | 288 | 4 → 1 | 0.59 × 1.5 | 1.255 |
| `chiu_rack_long` | rack | 300 | 3 → 1 | 1.88 × 0.43 | 0.795 |
| `chiu_rack_short` | rack | 276 | 3 → 1 | 1.38 × 0.43 | 0.795 |
| `mos_stack` | rack | 204 | 5 → 1 | 0.37 × 0.4 | 1.275 |
| `tubs_3x3` | rack | 168 | 3 → 1 | 0.53 × 1.3 | 0.555 |
| `tubs_3x3_long` | rack | 168 | 3 → 1 | 1.3 × 0.53 | 0.51 |
| `tubs_4x2_vertical` | rack | 240 | 3 → 1 | 0.53 × 1.56 | 0.475 |
| `tubs_4x2_wide` | rack | 240 | 3 → 1 | 1.56 × 0.53 | 0.475 |
| `tubs_shelf_long` | rack | 228 | 3 → 1 | 1.56 × 0.55 | 0.565 |
| `tubs_shelf_wide` | rack | 228 | 3 → 1 | 0.55 × 1.56 | 0.575 |
| `wooden_rack_long` | rack | 384 | 3 → 1 | 0.78 × 1.8 | 0.81 |
| `wooden_rack_wide` | rack | 384 | 3 → 1 | 1.8 × 0.78 | 0.93 |
| `library_trolley` | trolley | 264 | 4 → 1 | 0.41 × 0.88 | 0.74 |
| `aircon_unit` | wall_mount | 12 | 1 → 1 | 1.15 × 0.25 | 0.35 |
| `shelf_board_long` | wall_mount | 12 | 1 → 1 | 4.6 × 0.48 | 0.03 |
| `shelf_board_short` | wall_mount | 12 | 1 → 1 | 0.48 × 1.95 | 0.03 |
| `wall_shelves_big` | wall_mount | 36 | 3 → 1 | 4.65 × 0.05 | 0.76 |
| `wall_shelves_long` | wall_mount | 48 | 4 → 1 | 2.6 × 0.05 | 1.1 |
**Rejected (6):**
| source | why |
|---|---|
| `3dmrp.glb` | 499,948 tris (100x the 5k prop budget) + a 4096px texture. Named in the tasking as the one reject; measured and confirmed. |
| `DOOR.glb` | 0.010 m thick — a 1 cm plane, 12 tris. The solid door is DOOR-front.glb (0.19 m), which is accepted as door_shop. |
| `GREY COLUMN.glb` | 0.010 m thick plane, 12 tris. The solid post is GREY COLUMN-FRONT.glb (0.22 x 0.22 m), accepted as column_square. |
| `Shelf Level.glb` | 4.50 x 0.05 x 0.005 m — a 5 mm trim rail from the booth_room build script, not a placeable fitting. |
| `Shelf Level long.glb` | 2.60 x 0.05 x 0.005 m — same 5 mm trim rail, short version. |
| `floor crate.glb` | 0.34 x 0.34 x 0.002 m — a 2 mm floor decal. Would z-fight Lane C's interior floor; a real crate is crate_blue. |
### §41.2b — DJ gear · 31 shipped, node contract 4/4 PASS
**Ruling 5 and the draw budget were in direct conflict, and nobody had measured the second half.**
`gltf-transform optimize` is banned because it deleted `Fader_CROSS` — correct. But the reason it
was reached for is also real: measured on ultra's exports, these models cost **TR-1000 2,145 ·
MPC-XL 1,319 · TR-808 1,092 · PMC-08Pro 751 · TTM54i 659 draws**. Against a 162-draw margin, one
PMC-08Pro is 4.6× an entire room. Both "join everything" and "join nothing" ship something
unusable.
*(Also re-measured: the handover's "median 6,288 tris, 18 already ≤5k" is the stale
`dj-gear/manifest.json`. True median is **51,528 tris**; only **12** of 60 are ≤5k.)*
`pipeline/rig_join.py` joins **selectively**: scenery merges to one body, each contract node keeps
its own object, name and pivot. Two variants ship — `static` (dressing, 1 draw) and `rigged`
(drivable hero, 25 draws). Contract verified by `pipeline/check_contract.py`, which parses the
**shipped GLB's** node list rather than trusting the tool that wrote it.
| id | mode | tris | draws | source contract | shipped contract | verdict |
|---|---|---|---|---|---|---|
| `deck_1200_rigged` | rigged | 254,048 → 3,821 | 62 → 5 | Platter_SPIN,Arm_YAW,Fader_PITCH,Btn_STARTSTOP | Platter_SPIN,Arm_YAW,Fader_PITCH,Btn_STARTSTOP | PASS |
| `mixer_pmc05pro_rigged` | rigged | 61,872 → 5,000 | 525 → 2 | Fader_CROSS | Fader_CROSS | PASS |
| `mixer_shdj1200_rigged` | rigged | 86,577 → 4,999 | 776 → 2 | Fader_CROSS | Fader_CROSS | PASS |
| `mixer_ttm54i_rigged` | rigged | 75,788 → 5,000 | 659 → 2 | Fader_CROSS | Fader_CROSS | PASS |
| `cart_case` | static | 6,912 → 5,000 | 79 → 1 | — | — | N/A |
| `cart_concorde` | static | 2,853 → 2,853 | 34 → 1 | — | — | N/A |
| `cart_m447` | static | 1,387 → 1,387 | 25 → 1 | — | — | N/A |
| `cart_stanton` | static | 3,290 → 3,290 | 15 → 1 | — | — | N/A |
| `deck_1200` | static | 254,048 → 5,000 | 62 → 1 | Platter_SPIN,Arm_YAW,Fader_PITCH,Btn_STARTSTOP | — | STATIC (contract intentionally dropped) |
| `drum_808` | static | 118,569 → 5,000 | 1092 → 1 | — | — | N/A |
| `drum_tr1000` | static | 119,959 → 4,897 | 2145 → 1 | — | — | N/A |
| `headphones_hd25` | static | 8,495 → 4,999 | 169 → 1 | — | — | N/A |
| `headshell_technics` | static | 2,097 → 2,097 | 30 → 1 | — | — | N/A |
| `mixer_mx2200` | static | 69,127 → 5,000 | 411 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `mixer_pmc05pro` | static | 61,872 → 5,000 | 525 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `mixer_pmc06pro` | static | 59,633 → 5,000 | 506 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `mixer_pmc07pro` | static | 64,752 → 4,999 | 420 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `mixer_pmc08pro` | static | 137,885 → 5,000 | 751 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `mixer_pmc10` | static | 72,950 → 4,999 | 522 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `mixer_pmc20` | static | 91,127 → 5,000 | 775 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `mixer_pmx9000` | static | 130,053 → 5,000 | 807 → 1 | — | — | N/A |
| `mixer_shdj1200` | static | 86,577 → 5,000 | 776 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `mixer_ttm54i` | static | 75,788 → 4,999 | 659 → 1 | Fader_CROSS | — | STATIC (contract intentionally dropped) |
| `sampler_mpc3000` | static | 102,536 → 5,000 | 480 → 1 | — | — | N/A |
| `sampler_s900` | static | 86,536 → 5,000 | 443 → 1 | — | — | N/A |
| `sampler_sp1200` | static | 158,218 → 5,000 | 481 → 1 | — | — | N/A |
| `sl_deck_1` | static | 19,139 → 5,000 | 113 → 1 | — | — | N/A |
| `sl_deck_2` | static | 19,241 → 5,000 | 97 → 1 | — | — | N/A |
| `sl_deck_3` | static | 23,347 → 5,000 | 141 → 1 | — | — | N/A |
| `sl_deck_4` | static | 19,875 → 5,000 | 161 → 1 | — | — | N/A |
| `zip_drive` | static | 9,543 → 5,000 | 72 → 1 | — | — | N/A |
**`STATIC` = the contract is dropped by design** (a dressing copy is one joined mesh and is not
claimed to be drivable); **`N/A`** = the source carried no contract node (a cartridge has no
crossfader). **FAIL: 0.** Excluded as out-of-period: DIGITAKT-II, DIGITONE-II, POLYEND-DRUMS,
MPC-XL (2020s gear); the rest of the 60 are near-duplicate colourways.
### §41.2c — pub/venue props · 39 shipped, 3 rejected, 6 `staff*` skipped
Two handover claims failed re-measurement. **These are not metre-correct**: every source is
unit-normalised to a max dimension of exactly 1.00 m, so a picnic table and an ashtray are the same
size. Aspect *is* preserved, so a real-world height (read off each `*_shot.png` reference render)
fixes all three axes — the height table is `pipeline/r41_pub_batch.json`. And **textures are not
"all fine"**: 37 of 39 have no texture at all (they carry `COLOR_0` vertex colour), while `keg` and
`urinal_trough` carried 2048px maps, resized to 1024.
**40× decimation was impossible as specified.** COLLAPSE decimation cannot merge across overlapping
shells: `loungeChair` is **94% non-manifold** (376,076 of 399,542 verts) and a ratio-0.01 decimate
moved it 1,319,836 → 1,274,119, i.e. **3%**. `topiary` behaved identically at 93%; `pokie`, at 29%,
decimated normally. So `normalize.py` gained a `remesh` path (voxel remesh → decimate → vertex-colour
transfer), which needed two fixes found by measuring rather than assuming:
- **colour came back empty** until `bpy.ops.object.datalayout_transfer()` created the destination
layers — a Data Transfer modifier applied without it silently transfers nothing;
- **a voxel remesh needs a volume**, so thin-walled sources (`keg`) returned a wire cage at every
voxel size until a Solidify pass preceded the remesh
(`docs/shots/laneE/r41_shellsoup_solidify.png`).
| id | src tris | out tris | ratio | non-manifold | footprint (m) | height (m) |
|---|---:|---:|---:|---:|---|---:|
| `ash_urn` | 198,782 | 5,000 | 40× | 43% | 0.32 × 0.33 | 0.75 |
| `ashtray_post` | 198,710 | 5,000 | 40× | 42% | 0.41 × 0.41 | 0.95 |
| `bain_marie` | 184,992 | 4,958 | 37× | 83% | 0.89 × 0.76 | 0.95 |
| `banquette` | 190,350 | 4,999 | 38× | 68% | 1.55 × 1.02 | 0.85 |
| `beer_umbrella` | 192,752 | 4,995 | 39× | 60% | 2.68 × 2.65 | 2.3 |
| `bin_round` | 197,576 | 4,999 | 40× | 40% | 0.53 × 0.51 | 0.75 |
| `bistro_table` | 196,995 | 4,999 | 39× | 38% | 1.18 × 1.18 | 0.92 |
| `broken_basin` | 197,978 | 5,000 | 40× | 53% | 0.59 × 0.49 | 0.6 |
| `cable_snake` | 193,623 | 5,000 | 39× | 64% | 0.45 × 0.47 | 0.2 |
| `can_stack` | 196,005 | 5,000 | 39× | 46% | 0.38 × 0.32 | 0.45 |
| `cdj_deck` | 197,967 | 4,998 | 40× | 33% | 0.63 × 0.42 | 0.15 |
| `champ_bucket` | 183,943 | 4,999 | 37× | 62% | 0.24 × 0.2 | 0.35 |
| `concrete_plinth` | 190,528 | 5,000 | 38× | 53% | 0.76 × 0.74 | 1.2 |
| `crowd_barrier` | 189,524 | 5,000 | 38× | 60% | 1.97 × 0.3 | 1.1 |
| `distro_box` | 193,633 | 5,000 | 39× | 41% | 0.63 × 0.87 | 1.2 |
| `fire_ext` | 198,707 | 5,000 | 40× | 37% | 0.34 × 0.19 | 0.6 |
| `firepit` | 188,041 | 5,000 | 38× | 88% | 0.76 × 0.56 | 0.5 |
| `flight_case` | 195,452 | 5,000 | 39× | 37% | 0.6 × 0.36 | 0.75 |
| `jukebox` | 184,271 | 4,999 | 37× | 75% | 0.97 × 0.41 | 1.52 |
| `keg` | 441,861 | 5,000 | 88× | 23% | 0.4 × 0.4 | 0.6 |
| `lounge_chair` | 1,319,156 | 5,000 | 264× | 94% | 0.69 × 0.69 | 0.8 |
| `marble_sink` | 199,095 | 4,998 | 40× | 21% | 1.15 × 0.56 | 0.85 |
| `old_couch` | 196,277 | 5,000 | 39× | 46% | 1.8 × 0.95 | 0.85 |
| `pallet` | 198,086 | 5,000 | 40× | 30% | 0.79 × 0.73 | 0.2 |
| `picnic_table` | 197,370 | 4,999 | 39× | 42% | 1.38 × 1.41 | 0.75 |
| `pie_warmer` | 196,434 | 5,000 | 39× | 31% | 0.61 × 0.71 | 0.55 |
| `planter` | 239,862 | 4,897 | 49× | 87% | 0.98 × 0.96 | 0.55 |
| `plywood_bar` | 192,907 | 5,000 | 39× | 63% | 1.51 × 0.82 | 1.05 |
| `pokie` | 198,030 | 5,000 | 40× | 29% | 0.75 × 0.78 | 1.7 |
| `raffle_drum` | 193,830 | 5,000 | 39× | 68% | 0.37 × 0.37 | 0.9 |
| `rope_post` | 199,748 | 5,000 | 40× | 27% | 0.32 × 0.32 | 1.0 |
| `sandwich_board` | 198,359 | 5,000 | 40× | 25% | 0.58 × 0.56 | 1.0 |
| `smoke_machine` | 194,423 | 5,000 | 39× | 52% | 0.17 × 0.24 | 0.38 |
| `speaker_stack` | 186,357 | 5,000 | 37× | 59% | 0.52 × 0.52 | 1.8 |
| `strobe_light` | 193,392 | 5,000 | 39× | 57% | 0.22 × 0.24 | 0.4 |
| `sub_bass` | 193,581 | 4,998 | 39× | 53% | 0.87 × 0.63 | 0.7 |
| `topiary` | 415,872 | 4,999 | 83× | 93% | 0.61 × 0.61 | 1.2 |
| `urinal_trough` | 488,808 | 5,000 | 98× | 6% | 0.31 × 0.26 | 0.7 |
| `wheelie_bin` | 195,084 | 5,000 | 39× | 42% | 0.72 × 0.92 | 1.1 |
**Rejected (3) — eyeballed against each source's `*_shot.png`, per the house rule:**
| id | src tris | why |
|---|---:|---|
| `balustrade` | 198,229 | glass panel + handrail. The remesh lost the handrail entirely and left a lumpy slab with a detached lump — a transparent thin panel is the worst case for a signed-distance remesh. Eyeballed against balustrade_shot.png; unusable. |
| `cocktail_bar` | 194,298 | back-bar with a lit bottle display. At 5k the bottle shelving smears into a grey L-shaped mass; nothing about it reads as a bar. Rejected rather than shipping mush. |
| `dj_plinth` | 197,159 | the white plinth survives but the controller on top melts into a grey blob. Nothing is lost: cdj_deck normalized cleanly and Lane C can stand it on any counter or the plywood_bar. |
Plus the **6 `staff*.glb` skipped** as instructed (200k-tri statues, no skin/anim/material — and
PROCITY has a rigged ped fleet, so a photogrammetry human statue would read wrong beside it).
### Gate, publish, validate
**The R40 four-surface transmission gate earned its keep on live imports, not a fixture.** Sweeping
the 110 new assets caught **5 real `KHR_materials_transmission` materials** — cartridge dust-covers
(`cc_clear`, `n44_clear`, factor 0.850.9) and a mixer meter window (`meter_window`, 0.9). Each
would have made three.js issue *every opaque draw in that room twice*. `strip_transmission.py`
removed all 5 with **BIN chunk byte-identical** (sha1 printed per file). This is the control arm
R40 demonstrated with a synthetic fixture, firing on real cargo.
Full house-law sweep over all 110 — tris ≤5k, WebP ≤1024, no Draco, base-origin |minY| ≤5 mm, no
transmission, and `_normalized/` byte-identical to the `web/assets/models/` served mirror:
**0 violations.**
```
$ GOD3_DEPOT=http://100.94.195.115:8788 python3 pipeline/publish.py --files pipeline/_r41_publish_list.txt
published 110/110 this run → _published.json now 169 (was 59).
✓ _published.json matches the live depot (all 169 recorded assets live).
$ sha1 verify (fetch each back from the depot, compare bytes)
110/110 identical, 0 mismatched # → pipeline/_r41_sha1.json
32.4 MB on the depot
$ GOD3_DEPOT=http://100.94.195.115:8788 python3 pipeline/validate_manifest.py
manifest v1 — facades 49, skins 92 groups, GLBs 149 (+40 non-manifest GLBs transmission-swept), audio 34
OK — 0 errors, 0 warning(s) # rc=0
$ ... validate_manifest.py --depot # stricter: every GLB must be live on the depot
OK — 0 errors, 0 warning(s) # rc=0
```
`manifest.json` fittings **23 → 133**. Every new entry carries `footprint` [w,d] and `height` in
metres measured **from the shipped GLB** (not from the Blender scene that wrote it — `rig_join`'s
in-scene bounds mis-reported a 0.114 m mixer as 0.629 m, which is why the staging step re-measures
the file), plus `kind`, `tris` and `draws` so Lane C can spend the 162-draw margin deliberately.
DJ entries add `mode`, `control_nodes`, `source_controls` and `drivable`.
### Tools added / changed (all in `pipeline/`)
| file | what |
|---|---|
| `merge_prims.py` | **new** — bake `baseColorFactor``COLOR_0`, collapse N primitives to 1. The draw-cost fix. |
| `contact_sheet.py` | **new** — labelled multi-GLB sheet in one Blender run, the eyeball tool. |
| `thumbs.py` | **new** — EEVEE 256px thumbs; workbench `TEXTURE` renders vertex-coloured, material-less meshes flat grey, which is 2 of this round's 3 libraries. |
| `rig_join.py` | **new** — the selective join; static + contract-preserving rigged variants. |
| `check_contract.py` | **new** — contract assertion read from the shipped GLB. |
| `normalize.py` | `remesh` / `shell_thickness` / `vertex_colour` options + `nonmanifold_ratio()`; explicit `COLOR_0` export; **orientation bug fixed**`yaw`/`up` were applied without re-selecting the object, so `transform_apply` silently no-opped (the jukebox came out 0.40 m wide instead of 0.97 m). Now it re-selects and *verifies the dimensions changed*, warning if not. |
| `publish.py` | `--files LIST.txt` — publish an explicit set; `_normalized/` is shared with other lanes and a blanket run would push their half-written staging. |
| `build_manifest.py` | reads the three R41 result files; new entries carry kind/tris/draws/control-nodes. |
| `r41_3dstore.json`, `r41_pub_batch.json`, `r41_djgear_batch.json` | the by-name specs (ruling 3). |
### Proof shots (`docs/shots/laneE/`)
`r41_3dstore_40.png` · `r41_3dstore_merge_ab.png` (16-material source vs 1-draw merge, identical) ·
`r41_pub_42.png` · `r41_pub_before_after.png` · `r41_pub_rejects.png` (the 3 rejects beside their
sources) · `r41_shellsoup_solidify.png` (the keg cage vs the solidify fix) · `r41_djgear_31.png` ·
`r41_djgear_rigged.png` · `r41_djgear_merge_ab.png`.
### For Lane C (§41.4)
Everything is in `manifest.json` under `fittings`, keyed by the ids in the tables above. Place on
`footprint`/`height` untouched. Budget notes: **every static asset is 1 draw**; the four `*_rigged`
DJ heroes are 25 draws each and are the only ones with drivable `control_nodes`. The `-long`/
`-wide` 3dstore pairs are the same mesh rotated 90° — pick one, they are not two props. `kind`
tells you what it stands on: `bin`/`rack`/`counter`/`trolley` are floor-standing, `wall_mount`
needs a wall and a height, `arch` is structure, `prop` is a surface dressing.
## Round 40 (§40.1+40.4) · 2026-08-04 — *(entry here by explicit R40 tasking; cross-lane story in `docs/LANES/LANE_E_NOTES.md` §40)*
@ -432,3 +725,286 @@ All fixed and re-verified:
step before that path is free too.)*
3. A few library assets were near-misses (garage_shelves, radiogram, market_stall — see AUDIT
rejects). Worth a manual Blender re-origin pass later, or leave to primitives? I left them out.
---
# Round 41 (§41.1) — THE MOTION LIBRARY *(Lane E, motion half)*
**Done, green, and it is the round's critical path — Lane D can start.** 46 clips, 6 grouped GLBs,
**3 498 124 B (3.34 MB)**, zero-draw, plus a new `web/assets/motion_manifest.json`.
Full provenance + the licence lines are in `pipeline/AUDIT.md` under "Round 41 §41.1".
## 0. Ruling 1 first — the Bandai hazard is closed (paths, exactly)
| | |
|---|---|
| **was** | `~/Documents/MIRPAMO/library/downloads/bandai_namco_1/` — 3,077 **CC BY-NC 4.0** BVH, 725 MB, unzoned, neutrally named, **nothing in any filename marking them**. One `mirpamo batch` from a shipped build. |
| **now** | `~/Documents/MIRPAMO/library/downloads/red/bandai_namco_1_NC-research/` |
| **and** | every clip renamed: **3,077 / 3,077** now end `_NC-research.bvh`; `find … ! -name '*_NC-research.bvh'` returns **0** |
| **new** | `~/Documents/MIRPAMO/library/downloads/red/README.md` — the zone law, mirrored from ultra's `MOTIONLIB/red/README.md` |
| **fixed** | `~/Documents/MIRPAMO/library/manifest.json`: `CC-BY-NC-ND 4.0`**`CC BY-NC 4.0`** (+ `zone:"RED"`, `verified:true`, the renamed path as `name`, and the correction recorded in `notes`) |
**NOTHING WAS DELETED.** 552 MB of clip data is still on disk byte-for-byte; the instruction
preferred non-destructive and ultra holds the canonical zoned copy anyway
(`~/Documents/MOTIONLIB/red/bandai/`, re-verified live: **3,077 BVH / 552 MB**).
Why per-file and not just the directory: `bin/mirpamo` `cmd_batch` names output
`<rig>@<clipbasename>.glb`, so **the marker now propagates into any retargeted GLB automatically**.
`downloads/` is gitignored in MIRPAMO, so `red/` inherits that.
The ledger fix is real, not cosmetic: the bundled `dataset-1/LICENSE` and `dataset-2/LICENSE` both
open *"Attribution-NonCommercial 4.0 International"***no ND clause**, as ultra's `SOURCES.md`
already said. Also recorded while I was in there: `downloads/lafan1_ubisoft/` (the other
non-commercial source) is **scaffold only on this box** — 332 KB of 134-byte git-lfs pointers,
**0 BVH**, so it cannot leak; the real payload is ultra's `red/lafan1/`.
## 1. `mirpamo make smoke` — the tool ground truth, run BEFORE the batch
**PASSED**, Blender 5.1.2, exit 0 — procedural mesh → autorig → retarget → verify, `[mirpamo] verify OK`.
## 2. What ships
`web/models/clips/` (new dir), one fetch per group — ruling 6 is **6 files, not 46**:
| group file | category | clips | bytes | motion |
|---|---|---:|---:|---:|
| `idles.glb` | idle | 10 | 1 029 496 | 78.3 s |
| `social.glb` | social | 8 | 895 648 | 73.9 s |
| `venue.glb` | venue | 6 | 526 036 | 39.5 s |
| `browse.glb` | browse | 8 | 476 056 | 30.8 s |
| `sitlean.glb` | sitlean | 8 | 358 112 | 27.5 s |
| `locomotion.glb` | locomotion | 6 | 212 776 | 12.1 s |
| **TOTAL ADDED** | | **46** | **3 498 124 B = 3.34 MB** | **262 s** |
`web/assets/motion_manifest.json`**16 KB** (new file; I did not touch `manifest.json`, which is
the props half's this round). **Total added bytes: 3 514 636 (3.35 MB).**
The 8 clips PROCITY already ships cost **4.30 MB**. This is **5.75× the clips for 78% of that**.
## 3. ZERO-DRAW — proved with `glb_stat.py`, not asserted
```
browse.glb tris=0 meshes=0 materials=0 images=0 nodes=66 draco=False dims=[0,0,0]
idles.glb tris=0 meshes=0 materials=0 images=0 nodes=66 draco=False dims=[0,0,0]
locomotion.glb tris=0 meshes=0 materials=0 images=0 nodes=66 draco=False dims=[0,0,0]
sitlean.glb tris=0 meshes=0 materials=0 images=0 nodes=66 draco=False dims=[0,0,0]
social.glb tris=0 meshes=0 materials=0 images=0 nodes=66 draco=False dims=[0,0,0]
venue.glb tris=0 meshes=0 materials=0 images=0 nodes=66 draco=False dims=[0,0,0]
```
66 nodes = 65 mixamorig joints + `Armature`, i.e. the **byte-shape of the existing `idle.glb`**
(which is 66 nodes / 1 skin / 0 meshes / 0 tris). Confirmed again after loading in three.js:
0 meshes, 0 triangles added to the scene by any group file.
## 4. THE MANIFEST SCHEMA — `web/assets/motion_manifest.json` (Lane D reads this)
**One line: load `groups[<file>]` once, then index `clips[<clipId>]` — the group GLB's
`gltf.animations[]` are NAMED by `clipId`, so `animations.find(a => a.name === clipId)` is the whole
lookup.**
```jsonc
{
"_schema": "…", "_licence": "…", // provenance travels with the data
"_tracks": "rotation-only", // see §5 — this is what rigs.js keeps anyway
"_encoding": "rotation outputs are int16 NORMALIZED …; all 65 joints animated incl. fingers",
"_note": "skeleton-only, 0 tris, 66 nodes — same byte-shape as models/peds/idle.glb",
"fps": 30,
"groups": { // ONE ENTRY = ONE FETCH
"idles.glb": { "category":"idle", "desc":"…", "clips":[<clipId>,…],
"bytes":1029496, "nodes":66, "durationSum":78.3 }
},
"clips": { // 46 entries
"sit_bench_swing": {
"group": "sitlean.glb", // which file to fetch
"category": "sitlean",
"duration": 2.1333, // seconds, MEASURED from the keyframe times
"loopable": true, // safe to play on repeat
"loopSeamDeg": 0.04, // MEASURED: max per-bone angle, first key vs last key
"source": "Little_Girl_Sitting_On_A_Bench_Swinging_Her_Legs__c9c89165.fbx",
"mixamo": "Sitting", // the Mixamo catalogue title
"channels": 65, "rotBones": 65,
"pair": "<clipId>", // OPTIONAL — a matched two-person / open-close partner
"loopDemoted": "…" // OPTIONAL — present iff curated-loopable but seam too wide
}
},
"totalBytes": 3498124, "clipCount": 46, "groupCount": 6, "dedupSavedBytes": 1341724
}
```
Consumer notes for D: the clips ride the **same `_canon` + `_rotOnly` path as `walk`/`idle`** — 65
raw tracks in, **64 out** (Hips.quaternion dropped). `loadPedFleet` today takes `r.anims[0]`; a
group file has N, so read them by name. Everything stays behind your gate — I added no loader code
and touched no file under `web/js/`.
## 5. Where the bytes went — three levers, each measured
Baseline, straight out of Blender as 46 separate GLBs: **8.20 MB**.
| lever | → | why it costs nothing real |
|---|---:|---|
| rotation-only tracks | 7.71 MB | `_rotOnly` decodes every translation/scale track and discards it the same frame. Dead bytes in this engine, not fidelity. `--tracks all` restores them |
| accessor dedup by content | 5.98 MB | lossless — **1 341 724 B** of byte-identical keyframe payloads collapsed |
| int16-normalized rotations | **3.34 MB** | glTF-legal; three r175 de-normalises in `GLTFLoader._getArrayFromAccessor` (checked in `web/vendor/`). **Worst angular error across all 46 clips: 0.0034°** |
**The lever I did NOT pull, for D:** finger channels are **49.7% of the payload** (measured — 3.37 MB
of the pre-quantisation total). `python3 pipeline/clips_pack.py --drop-fingers` takes the whole set
to **1.71 MB**. I kept fidelity; say the word and it is one flag and one minute.
## 6. Why there is NO retarget — and why that is right
The Mixamo bank and PROCITY's ped fleet are **the same mixamorig skeleton**. That is the premise of
the house one-canonical-clip-bank law (`rigs.js` `_canon`). Running mixamorig→mixamorig through
MIRPAMO's retargeter would (a) add transfer error for nothing and (b) **emit the ped MESH into the
clip file**, killing the zero-draw property that makes this round affordable. MIRPAMO's retargeter
is for foreign skeletons (its CMU bonemap) and for rigging raw meshes — it was still run (`make
smoke`, green) as the tool check. The Blender-5 slotted-action gotcha is avoided **by construction**:
nothing goes out through FBX at any stage — FBX in, glTF out.
## 7. The clips, by category (46) — `clipId | Mixamo title | duration | loopable | seam° | source FBX`
### idles.glb — idle (10) — the clone-army killer
| clip id | Mixamo title | dur | loop | seam° | source FBX |
|---|---|---:|:---:|---:|---|
| `idle_breathing` | Breathing Idle | 9.97s | yes | 0.0 | `Breathing_Idle__c9c6d0d5.fbx` |
| `idle_standing` | Standing Idle | 6.03s | yes | 0.0 | `Standing_Idle__c9c831cf.fbx` |
| `idle_look_around` | Looking Around | 6.37s | yes | 0.0 | `Idle_Stand_Looking_Around__c9c6cdf7.fbx` |
| `idle_weight_shift` | Weight Shift | 9.47s | yes | 0.0 | `Shifting_Weight_From_Side_To_Side__c9c908f7.fbx` |
| `idle_cocky_lean` | Being Cocky | 2.93s | no | 0.0 | `Cocky_Lean_Back__c9c90e3c.fbx` |
| `idle_yawn` | Yawn | 8.37s | no | 0.0 | `Big_Yawn_While_Standing__c9c98361.fbx` |
| `idle_happy_1` | Happy Idle | 2.03s | yes | 0.3 | `Happy_Idle_Variation_1__c9ccf37e.fbx` |
| `idle_happy_2` | Happy Idle | 2.97s | yes | 0.0 | `Happy_Idle_Variation_2__c9ccf445.fbx` |
| `idle_old_man` | Old Man Idle | 12.20s | yes | 0.0 | `Old_Man_Standing_Idle__c9c6c004.fbx` |
| `idle_smoking` | Smoking | 17.93s | yes | 0.4 | `Idle_Smoking__c9c603fd.fbx` |
### browse.glb — browse (8)
| clip id | Mixamo title | dur | loop | seam° | source FBX |
|---|---|---:|:---:|---:|---|
| `browse_hold_idle` | Holding Idle | 6.03s | yes | 0.1 | `Holding_An_Object_Idle__c9cda11a.fbx` |
| `browse_hold_walk` | Holding Walk | 1.40s | yes | 6.3 | `Holding_An_Object_While_Walking__c9cda1e7.fbx` |
| `browse_hold_turn_l` | Holding Turn Left | 1.23s | no | 172.5 | `Holding_An_Object_While_Turning_Left__c9cda2af.fbx` |
| `browse_hold_turn_r` | Holding Turn Right | 1.33s | no | 147.3 | `Holding_An_Object_While_Turning_Right__c9cda378.fbx` |
| `browse_open_cabinet` | Opening | 4.83s | no | 0.0 | `Opening_Cabinet_Door__c9c6d49e.fbx` |
| `browse_close_cabinet` | Closing | 6.20s | no | 0.0 | `Closing_Cabinet_Door__c9c6d55d.fbx` |
| `browse_carry_box` | Box Idle | 6.33s | yes | 0.0 | `Carrying_A_Box__c9cd8d90.fbx` |
| `browse_pick_up` | Picking Up Object | 3.47s | no | 92.2 | `Picking_Up_An_Object_With_One_Hand__c9c6c5eb.fbx` |
### sitlean.glb — sitlean (8)
| clip id | Mixamo title | dur | loop | seam° | source FBX |
|---|---|---:|:---:|---:|---|
| `sit_bench_swing` | Sitting | 2.13s | yes | 0.0 | `Little_Girl_Sitting_On_A_Bench_Swinging_Her_Legs__c9c89165.fbx` |
| `sit_hands_thighs` | Sitting Idle | 6.43s | yes | 0.1 | `Sitting_In_Chair_Hands_Resting_On_Thighs__c9cb9783.fbx` |
| `sit_impatient` | Sitting | 7.03s | yes | 0.0 | `Sitting_And_Impatiently_Waiting__c9ccad56.fbx` |
| `sit_fidget_feet` | Sitting | 1.20s | yes | 0.2 | `Sit_With_Fidgeting_Feet__c9c699a5.fbx` |
| `lean_wall_postures` | One Shoulder Lean | 2.03s | yes | 0.0 | `Leaning_Against_A_Wall_With_Various_Postures__c9c695f0.fbx` |
| `lean_wall_leg_up` | Leaning | 3.37s | yes | 0.0 | `Leaning_Square_Against_A_Wall_With_One_Leg_Up__c9c696b0.fbx` |
| `lean_wall_shoulders` | Leaning | 3.37s | yes | 0.0 | `Leaning_Square_With_Shoulders_Against_A_Wall__c9c69770.fbx` |
| `lean_wall_walkman` | Leaning On A Wall | 1.97s | yes | 0.0 | `Little_Girl_Leaning_On_A_Wall_Listening_To_Music__c9c8bace.fbx` |
### social.glb — social (8)
| clip id | Mixamo title | dur | loop | seam° | source FBX |
|---|---|---:|:---:|---:|---|
| `social_conversation` | Talking | 10.30s | yes | 0.3 | `General_Conversation__c9c88301.fbx` |
| `social_chat_water` | Talking At Watercooler | 44.60s | yes | 0.8 | `Having_A_Chat_At_The_Watercooler__c9c9f1c9.fbx` |
| `social_greeting` | Standing Greeting | 5.13s | no | 0.0 | `Greeting_While_Standing__c9c7996a.fbx` |
| `social_shake_1` | Shaking Hands 1 | 4.40s | no | 0.0 | `2_People_Shaking_Hands_Part_1_-_Female__c9c9f343.fbx` |
| `social_shake_2` | Shaking Hands 2 | 4.40s | no | 0.1 | `2_People_Shaking_Hands_Part_2_-_Male__c9c9f403.fbx` |
| `social_nod_yes` | Head Nod Yes | 2.63s | no | 0.0 | `Nodding_Head_Yes__c9c90d7b.fbx` |
| `social_shake_no` | Shaking Head No | 1.83s | no | 0.0 | `Shaking_Head_No_Dismissively__c9c90b3c.fbx` |
| `social_wave` | Waving | 0.57s | no | 0.0 | `Waving__c9c5ed32.fbx` |
### locomotion.glb — locomotion (6)
| clip id | Mixamo title | dur | loop | seam° | source FBX |
|---|---|---:|:---:|---:|---|
| `walk_shopping_bag` | Walking With Shopping Bag | 1.30s | yes | 0.0 | `Male_Walking_With_Shopping_Bag__c9c64027.fbx` |
| `turn_left_90` | Unarmed Turn Left 90 | 1.53s | no | 84.6 | `Turning_90_Degrees_Left__c9ceef5f.fbx` |
| `turn_right_90` | Unarmed Turn Right 90 | 1.53s | no | 83.2 | `Turning_90_Degrees_Right__c9cef01d.fbx` |
| `turn_walk_180` | Walking Turn 180 | 1.03s | no | 180.0 | `Turning_180_Degrees_While_Walking__c9c9c73f.fbx` |
| `turn_in_place` | Turning | 3.70s | no | 36.7 | `Turning_In_Place__c9c70ef0.fbx` |
| `walk_to_stand` | Stop Walking | 3.03s | no | 55.3 | `Walking_To_Standing_Idle__c9c8d966.fbx` |
### venue.glb — venue (6)
| clip id | Mixamo title | dur | loop | seam° | source FBX |
|---|---|---:|:---:|---:|---|
| `venue_clap_stand` | Clapping | 1.20s | yes | 0.1 | `Clap_While_Standing__c9c60ef2.fbx` |
| `venue_clap_seated` | Clapping | 1.20s | yes | 0.1 | `Clap_While_Seated__c9c60fce.fbx` |
| `venue_cheer` | Cheering | 2.93s | yes | 0.0 | `Male_Cheering_With_Two_Fists_Pump__c9c66dfd.fbx` |
| `venue_northern_soul` | Northern Soul Dance | 16.10s | yes | 0.4 | `Basic_Northern_Soul_Step__c9cbe56d.fbx` |
| `venue_bartending` | Bartending | 13.60s | yes | 0.0 | `Pouring_And_Serving_Drinks__c9c78799.fbx` |
| `venue_headphones` | Listening To Music | 4.50s | yes | 0.0 | `Male_With_Headphones_Listening_To_Music__c9c63329.fbx` |
## 8. Curation — three substitutions, because the named seed was wrong for this town
Every §41.1 seed was checked against `index.json` by name. Three resolved to something the brief
did not intend:
| §41.1 named | what it actually is in the bank | shipped instead |
|---|---|---|
| the whole `45/90/135/180_Degree_{L,R}_Turn` set | **rifle-aiming.** Every single one is Mixamo "Rifle Turn" or "Rifle Crouch Turn". A shopping town does not want citizens sweeping the street down a barrel | the civilian set: `Turning_90_Degrees_Left/Right` (Mixamo **"Unarmed Turn 90"**), `Turning_180_Degrees_While_Walking`, `Turning_In_Place`, `Walking_To_Standing_Idle` |
| `Examine_Forwards` / `_Backwards` | **static poses, not motion** — 262 KB single-frame entries titled "Female Crouch Pose" / "Female Action Pose" | `Opening_Cabinet_Door` + `Picking_Up_An_Object_With_One_Hand` |
| `Legs_Crossed_Leaning_On_Object` | static pose, "Male Standing Pose", 262 KB | `Leaning_Square_Against_A_Wall_With_One_Leg_Up` + `…_With_Shoulders_Against_A_Wall` |
`2_People_Shaking_Hands_Part_1/2` **does** exist (`…_-_Female` / `…_-_Male`) and **ships as a pair**,
flagged `"pair"` in the manifest — as do the two holding-turns and the cabinet open/close.
Two picks are deliberately for *this* game rather than generic: `Idle_Smoking` and
`Male_Walking_With_Shopping_Bag` (1990s Australian high street), plus `Pouring_And_Serving_Drinks`
for the pub and `Male_With_Headphones_Listening_To_Music` for the record shop.
## 9. Verification — every claim above is a measurement
**`pipeline/clips_verify.py` → GREEN, 0 errors, 0 warnings** (new, re-runnable; **F may gate on it**
— it is the "every referenced clip resolves" check). It asserts: file sizes match the manifest ·
every clip id resolves to a named animation and nothing extra rides along · every group is zero-draw
and Draco-free · all six carry the identical 66-node skeleton · every channel targets a real node ·
every declared duration equals the keyframe times in the bytes · every clip survives `_rotOnly` with
tracks left · quantisation error is bounded.
**In the repo's own vendored three.js r175**, through `GLTFLoader` + `SkeletonUtils.clone` of a real
`man_casual_01.glb`, all 46 clips: **65 raw tracks → 64 after `_rotOnly`**, durations match to 3
decimals, every sampled bone world-position finite and moving, 0 meshes / 0 tris added.
**The R16 trap was checked for on purpose.** R16 found that keeping `Hips.quaternion` on a seated
clip lays the body flat (head folded to hip height). Spine tilt (Hips→Head vs +Y) sampled at 24
points across every clip: **0 frames over 75°, on all 46.** Worst is 36.5° (`social_chat_water` —
a lean on the watercooler); the seated family sits at 2728°, a natural seated forward lean.
**Nothing folds.**
**Loopability is measured.** `loopSeamDeg` = max per-bone angle between first and last rotation key.
28 of 46 loopable. One curated-loopable clip was **auto-demoted by the packer**`turn_in_place`,
36.7° seam — and carries `loopDemoted` saying so. The one-shots read exactly as expected
(`browse_hold_turn_l` 172.5°, `turn_left_90` 180°): they are turns, they are not supposed to close.
## 10. Re-running it (all $0, all on-device, ~2 minutes)
Added to `pipeline/`: `clips_r41.json` (the checked-in selection — **every asset by name, no
wildcard, ruling 3**), `clips_export.py` (Blender), `clips_pack.py` (pure stdlib), `clips_verify.py`.
```bash
# 1. fetch the 46 named FBX from ultra's GREEN Mixamo bank (BY NAME — never a wildcard)
python3 - <<'EOF' > /tmp/fbx_list.txt
import json;print('\n'.join(c['file'] for g in json.load(open('pipeline/clips_r41.json'))['groups'].values() for c in g['clips']))
EOF
ssh johnking@100.91.239.7 "cd ~/Documents/MOTIONLIB/green/mixamo_full && tar czf - $(tr '\n' ' ' </tmp/fbx_list.txt)" | tar xzf - -C /tmp/fbx
# 2. FBX -> per-clip skeleton-only GLB (local Blender 5.1.2; FBX in, glTF out — never FBX out)
/Applications/Blender.app/Contents/MacOS/Blender --background --python pipeline/clips_export.py -- \
--list pipeline/clips_r41.json --fbx /tmp/fbx --out /tmp/clip_glb
# 3. merge into the <=6 grouped GLBs + write the manifest
python3 pipeline/clips_pack.py --stage /tmp/clip_glb --out web/models/clips \
--manifest web/assets/motion_manifest.json # [--drop-fingers] [--no-quantize] [--tracks all]
# 4. gate
python3 pipeline/clips_verify.py --stage /tmp/clip_glb # 0 errors = green
python3 pipeline/glb_stat.py --json web/models/clips # tris must be 0 on all six
```
## 11. Handoff
**Lane D (41.3) — you are unblocked.** `web/assets/motion_manifest.json` is the contract; §4 above is
the schema. `web/models/clips/*.glb` are plain uncompressed GLBs, no Draco, no decoder. Nothing in
`web/js/**` was touched — no loader code, no gate, no default changed, so `?noassets=1` and
`?classic=1` are byte-identical to R40 until you wire it. The per-group `bytes` are in the manifest
so you can pick what boots: `sitlean` + `locomotion` together are **571 KB**; the whole set is
**3.34 MB in 6 fetches**. If that is too much, `--drop-fingers` halves it in one minute.
**Lane F (41.6):** `python3 pipeline/clips_verify.py` is a ready-made clip-manifest-integrity gate —
exit 0 = green, and it fails loudly on a missing file, a renamed clip, a size drift, a non-zero-draw
group or a skeleton mismatch. `pipeline/glb_stat.py --json web/models/clips` is the zero-draw proof.
**Files I touched, and only these:** `web/models/clips/{idles,browse,sitlean,social,locomotion,venue}.glb`
(new) · `web/assets/motion_manifest.json` (new) · `pipeline/{clips_r41.json,clips_export.py,clips_pack.py,clips_verify.py}`
(new) · `pipeline/AUDIT.md` · `pipeline/README.md` · `E-progress.md` · `docs/LANES/LANE_E_NOTES.md`.
Outside the repo, for ruling 1 only: `~/Documents/MIRPAMO/library/**`. **No git command was run.**
I did not touch `web/assets/manifest.json` (props half's), `web/js/**`, or `tools/qa.sh`.

View File

@ -1,5 +1,80 @@
# LANE E — cross-lane notes
## Round 41 — 110 interior fittings land, and the number that mattered was DRAWS ⟨41.2⟩
Shots: `docs/shots/laneE/r41_3dstore_40.png` · `r41_pub_42.png` · `r41_djgear_31.png` ·
`r41_pub_rejects.png` · `r41_3dstore_merge_ab.png`. Full tables: `E-progress.md` § Round 41.
### → LANE C: §41.4 — your stock is in `manifest.json` `fittings` (23 → 133). Place on the numbers untouched.
**Read this first, it is the whole hand-off:** every asset below is **1 draw** except four DJ heroes
(25). That was not free — as handed over, this cargo cost **14,140 draws**; it ships at **117**.
The interior margin is 162, so the naive import would have been 87× over budget on draws while
passing every triangle check. Per-entry `tris` and `draws` are now IN the manifest so you can spend
the margin without guessing.
Each entry: `file` · `footprint` [w,d] m · `height` m · `thumb` · `kind` · `tris` · `draws` ·
`source`. DJ entries add `mode` (`static`|`rigged`), `control_nodes`, `source_controls`, `drivable`.
**`kind` tells you how to place it:**
| kind | n | placement | ids |
|---|---:|---|---|
| `bin` | 14 | floor-standing record bins, browsable | `bin_endoffunk`, `chiu_bin`, `chiu_bin_half`, `chiu_bin_low`, `crate_blue`, `mos_bin_bottom`, `mos_bin_lower_mid`, `mos_bin_top`, `mos_bin_upper_mid`, `rack_bin_wooden`, `tub_black`, `tub_black_long`, `tub_white`, `tub_white_long` |
| `rack` | 12 | floor-standing multi-tier racks | `bin_big_4x4`, `chiu_rack_long`, `chiu_rack_short`, `mos_stack`, `tubs_3x3`, `tubs_3x3_long`, `tubs_4x2_vertical`, `tubs_4x2_wide`, `tubs_shelf_long`, `tubs_shelf_wide`, `wooden_rack_long`, `wooden_rack_wide` |
| `counter` | 2 | service counters | `counter_station`, `counter_station_side` |
| `trolley` | 1 | wheeled | `library_trolley` |
| `prop` | 2 | surface dressing | `vinyl_sleeve`, `window_booth` |
| `wall_mount` | 5 | **needs a wall + a mount height — these have no legs** | `aircon_unit`, `shelf_board_long`, `shelf_board_short`, `wall_shelves_big`, `wall_shelves_long` |
| `arch` | 4 | structure (door/columns/beam) | `beam_roof`, `column_square`, `column_wide`, `door_shop` |
| `pub` | 39 | pub/cafe/milkbar/venue, floor-standing | see below |
| `djgear` | 31 | record shop + gig venue hero props | see below |
**Pub/venue (39):** `ash_urn`, `ashtray_post`, `bain_marie`, `banquette`, `beer_umbrella`, `bin_round`, `bistro_table`, `broken_basin`, `cable_snake`, `can_stack`, `cdj_deck`, `champ_bucket`, `concrete_plinth`, `crowd_barrier`, `distro_box`, `fire_ext`, `firepit`, `flight_case`, `jukebox`, `keg`, `lounge_chair`, `marble_sink`, `old_couch`, `pallet`, `picnic_table`, `pie_warmer`, `planter`, `plywood_bar`, `pokie`, `raffle_drum`, `rope_post`, `sandwich_board`, `smoke_machine`, `speaker_stack`, `strobe_light`, `sub_bass`, `topiary`, `urinal_trough`, `wheelie_bin`.
**DJ gear — static, 1 draw each (27):** `cart_case`, `cart_concorde`, `cart_m447`, `cart_stanton`, `deck_1200`, `drum_808`, `drum_tr1000`, `headphones_hd25`, `headshell_technics`, `mixer_mx2200`, `mixer_pmc05pro`, `mixer_pmc06pro`, `mixer_pmc07pro`, `mixer_pmc08pro`, `mixer_pmc10`, `mixer_pmc20`, `mixer_pmx9000`, `mixer_shdj1200`, `mixer_ttm54i`, `sampler_mpc3000`, `sampler_s900`, `sampler_sp1200`, `sl_deck_1`, `sl_deck_2`, `sl_deck_3`, `sl_deck_4`, `zip_drive`.
**DJ gear — rigged heroes (the only drivable ones):**
| id | draws | control nodes |
|---|---:|---|
| `deck_1200_rigged` | 5 | `Platter_SPIN`, `Arm_YAW`, `Fader_PITCH`, `Btn_STARTSTOP` |
| `mixer_ttm54i_rigged` | 2 | `Fader_CROSS` |
| `mixer_pmc05pro_rigged` | 2 | `Fader_CROSS` |
| `mixer_shdj1200_rigged` | 2 | `Fader_CROSS` |
Drive them by their **bare node name** (`dj-gear/README.md`: the cap/stem/rib are parented under
it, so moving the node moves the whole control). glTF drops the source's limit constraints, so
**clamp the travel yourself**. Place at most one or two — a static copy is 1 draw and looks the
same standing still.
**Three traps:**
1. The 3dstore `-long`/`-wide` pairs (`tubs_4x2_wide`/`_vertical`, `tubs_3x3`/`_long`,
`tubs_shelf_long`/`_wide`, `wooden_rack_long`/`_wide`, `chiu_rack_long`/`_short`,
`counter_station`/`_side`) are **the same mesh rotated 90°**. Pick one per placement; shipping
both into one room is paying twice for one prop.
2. `vinyl_sleeve` is a 3 mm record sleeve — its footprint depth is floored to 0.01 m so the
validator accepts it. It is a surface/wall dressing, not something to stand on the floor.
3. `beam_roof` is 6.5 m long and `shelf_board_long` 4.6 m — both are longer than most interiors are
wide. Check the room before placing.
### → LANE F: §41.6 — the R40 four-surface gate held over 110 new assets, and it caught real cargo
`validate_manifest.py` is **rc 0, 0 errors, 0 warnings** with `GLBs 149 (+40 non-manifest GLBs
transmission-swept)`, and also rc 0 under `--depot`. Nothing about the gate changed this round; it
was exercised.
**It fired on live imports, not a fixture.** The sweep caught **5 real
`KHR_materials_transmission` materials** in the DJ gear — three cartridge dust-covers (`cc_clear`,
`n44_clear`, factor 0.850.9) and a mixer meter window (`meter_window`, 0.9). Those are *plausible*
glass, which is exactly the R39 failure mode (nobody looks at a material that has a reason to be
transmissive). Each would have made three.js issue every opaque draw in that room twice.
`strip_transmission.py` removed all five with the **BIN chunk byte-identical**. R40 demonstrated
this control with a synthetic fixture; R41 is the same control catching real cargo.
All 110 are published to the tailnet depot and **sha1-verified byte-identical on fetch-back**
(`pipeline/_r41_sha1.json`); `_published.json` 59 → 169 and matches the live depot.
## Round 40 — the transmission gate grows eyes on every load path, and the arcade kit is a rule, not an asset ⟨40.1 + 40.4⟩
Shots: `docs/shots/laneE/r40_arcade_posts_before.png` · `r40_arcade_posts_after.png` ·
@ -1687,3 +1762,27 @@ gate 3 after your sign-off.
- **E2a record_crate: DONE + verified** (loads in vendored GLTFLoader). Re-published.
- **E2b counter: in progress** (local, on-device — no fal spend per decision #3).
- Not doing: fal.ai spend (glass-case/bus-shelter stay parked), hero-prop bake-to-lowpoly.
---
## Round 41 §41.1 — the motion library (E, motion half)
- **Ruling 1 closed first.** 3,077 CC BY-NC Bandai-Namco BVH on m3ultra were unzoned and unmarked;
now `~/Documents/MIRPAMO/library/downloads/red/bandai_namco_1_NC-research/` with `_NC-research`
on **every** file (3,077/3,077, 0 unmarked) — `mirpamo batch` names output `<rig>@<clip>.glb`, so
the marker propagates automatically. Nothing deleted (552 MB intact; ultra's `red/bandai/` is the
canonical zoned copy). MIRPAMO's `library/manifest.json` licence corrected `CC-BY-NC-ND 4.0`
**`CC BY-NC 4.0`** (the bundled LICENSE files say Attribution-NonCommercial, no ND), matching
ultra's `MOTIONLIB/SOURCES.md`.
- **46 clips, 6 grouped GLBs, 3 498 124 B, 0 tris**`web/models/clips/` + new
`web/assets/motion_manifest.json` (16 KB). 5.75× the clips the game shipped, for 78% of their bytes.
- **Pipeline:** `pipeline/clips_r41.json` (named selection) → `clips_export.py` (Blender) →
`clips_pack.py` (stdlib merge) → `clips_verify.py` (gate, GREEN). See `pipeline/README.md` §10.
- **No retarget:** the bank and the peds are the same mixamorig skeleton. Retargeting would add
error and bake the ped mesh into the clip, ending zero-draw. `mirpamo make smoke` green anyway.
- **Curation note for future rounds:** the `*_Degree_{L,R}_Turn` clips in the Mixamo bank are all
**rifle-aiming**; the civilian equivalents are `Turning_90_Degrees_{Left,Right}` ("Unarmed Turn").
Bank entries at ~262 KB whose Mixamo title ends "Pose" are **single-frame static poses**, not
motion — check `kb` before picking.
- **Standing lever for D/F:** finger channels are 49.7% of the clip payload;
`clips_pack.py --drop-fingers` takes the set 3.34 MB → 1.71 MB in one minute.

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 619 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 822 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 885 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

View File

@ -5,6 +5,36 @@
`pipeline/glb_stat.py` (dependency-free GLB parser); orientation & final scale verified by
rendering every candidate through Blender and looking at the thumbnails (never "should look right").*
## R41 §41.2 — provenance for the three imported libraries (2026-08-07)
All three are John's own local libraries; none is a vendor pack and none touches the ruling-3
deny-list. Every asset entered **by name** through a committed spec, never a wildcard copy.
| library | path | licence zone | spec | shipped |
|---|---|---|---|---|
| shop fittings | `~/Documents/3dstore/` (this box) | in-house — built in Blender by John (`*.blend` + `build_booth_room_v*.py` sit beside the GLBs) | `pipeline/r41_3dstore.json` | 40 of 46 |
| pub/venue props | `~/Documents/not-tonight/art_incoming/` (this box) | in-house — `trimesh` generator output from John's `not-tonight` project, each with its own `*_shot.png` reference render | `pipeline/r41_pub_batch.json` | 39 of 48 |
| DJ gear | ultra `~/Documents/3D=models/dj-gear/glb/` | in-house — exported 2026-08-01 from John's `~/Documents/opusblender` blends | `pipeline/r41_djgear_batch.json` | 31 of 60 |
**Trademark note, carried forward from the R4 Akai-MPC flag:** the DJ-gear ids name real hardware
(`drum_808`, `sampler_mpc3000`, `sampler_sp1200`, `sampler_s900`, `mixer_pmc*`, `deck_1200`,
`headphones_hd25`, `cart_m447`). The meshes are John's own reconstructions, not vendor assets, and
the source `README` records that several are already wired into 90sDJsim. Same standing caveat as
R4: **fine in-house; flag before any public/commercial release**, where in-world text should use
generic names.
**Method (measured, not quoted).** Tri counts, bounding boxes, texture sizes, Draco and
transmission flags from `pipeline/glb_stat.py`; **draw counts** from a direct node→mesh→primitive
walk of each GLB, which is the number `GLTFLoader` turns into `Mesh` objects and the one the
handover notes omitted. Footprints and heights are re-measured **from the shipped GLB** after
export, not from the Blender scene that wrote it — `rig_join.py`'s in-scene bounds mis-reported a
0.114 m mixer as 0.629 m, and Lane C places on these numbers. Every asset was eyeballed on a
contact sheet against its source (`docs/shots/laneE/r41_*.png`); the 9 rejects are listed with
reasons in `E-progress.md` § Round 41.
**Documented budget exceptions: none.** All 110 land ≤5k tris, WebP ≤1024, no Draco, base-origin.
## TL;DR
- The library is a **raw dump**: almost nothing obeys the house GLB law. Scale is arbitrary
@ -1100,3 +1130,135 @@ GLBs 36 → 39); **`validate_manifest.py`: 0 errors, 0 warnings.**
**Licence unchanged 🟢, $0.** No geometry, no texture and no third-party asset was added or altered
— one extension object was deleted from three material blocks.
## Round 41 §41.1 — THE MOTION LIBRARY (Lane E, motion half)
*Appended by E-motion. Numbers below are measured with `pipeline/glb_stat.py`,
`pipeline/clips_verify.py` and an in-shell three.js r175 probe — not quoted from the survey.*
### 0. Ruling 1 — THE BANDAI HAZARD, CLOSED (done first, before any clip work)
3,077 **CC BY-NC 4.0** (non-commercial, never shippable) Bandai-Namco Research BVH sat on m3ultra in
a neutrally-named, unzoned path with nothing in any filename marking them:
`~/Documents/MIRPAMO/library/downloads/bandai_namco_1/` (725 MB incl. the repo's `src/` figures;
552 MB of clips: dataset-1 = 175 BVH, dataset-2 = 2,902 BVH).
**Renamed, not deleted** (non-destructive, per the instruction's preference — 552 MB of data still
on disk, byte-for-byte):
| what | before | after |
|---|---|---|
| the tree | `MIRPAMO/library/downloads/bandai_namco_1/` | `MIRPAMO/library/downloads/red/bandai_namco_1_NC-research/` |
| every clip | `dataset-1_walk_normal_001.bvh` | `dataset-1_walk_normal_001_NC-research.bvh`**3,077/3,077 renamed, 0 unmarked left** |
| the zone | *(none — MIRPAMO had no zone system)* | new `downloads/red/README.md`, mirroring ultra's `MOTIONLIB/red/README.md` |
Why per-file and not just the directory: `bin/mirpamo` `cmd_batch` names its output
`<rig>@<clipbasename>.glb`, so the suffix **propagates into every retargeted GLB automatically**
a non-commercial clip can no longer reach a shipped build without `_NC-research` in its filename.
`downloads/` is already gitignored in MIRPAMO, so `red/` inherits that and nothing gets committed.
**Ledger reconciled.** `MIRPAMO/library/manifest.json` said `CC-BY-NC-ND 4.0`. The bundled
`dataset-1/LICENSE` and `dataset-2/LICENSE` both open with *"Attribution-NonCommercial 4.0
International"* — **CC BY-NC 4.0, no ND clause**, which is what ultra's corrected
`~/Documents/MOTIONLIB/SOURCES.md` says. The entry now carries the correct licence, `"zone": "RED"`,
`"verified": true`, the renamed path as its `name` (so a future `fetch.py` run lands in the marked
path too), and a note recording the correction. The `lafan1_ubisoft` entry — also non-commercial —
got the same zone field plus the measured fact that **this box's copy is scaffold only**: 0 BVH,
332 KB of 134-byte git-lfs pointer files.
Canonical correctly-zoned copy verified live on ultra: `~/Documents/MOTIONLIB/red/bandai/`,
**3,077 BVH / 552 MB**. Nothing was lost by not deleting; nothing can now leak by accident.
### 1. Provenance and licence — 🟢 ONE REGIME, MIXAMO ONLY (ruling 2)
| field | value |
|---|---|
| source | ultra `~/Documents/MOTIONLIB/green/mixamo_full/` — 2,394 anim-only skinless FBX + `index.json` |
| licence | **Mixamo (Adobe)** — free to use inside games/projects, **no attribution requirement** |
| the one rule | **never redistribute or resell as clip packs.** Retargeted per-character clips inside the game are explicitly fine — which is exactly what ships here |
| attribution debt | **none.** R41 adds nothing to the 41.5 credits surface |
| cost | **$0** — local Blender 5.1.2, no cloud, no key |
| deny-list (ruling 3) | untouched. Nothing entered `web/` from `character_kit_modular/exports/bodies/**`, any `anatomy/`, `elsa_coronation_hair_wig_kh3.glb`, `daphne_sexy.glb` or `~/Documents/mocaponline/**`. **No wildcard copy was used at any step** — the 46 source FBX were fetched by explicit name from a checked-in list (`pipeline/clips_r41.json`), each one verified present in `index.json` with a matching Mixamo title before the copy ran |
| CMU / 100STYLE / ACCAD | **not touched.** All attribution-bearing; they wait on 41.5 |
### 2. What shipped — 46 clips, 6 grouped GLBs, 3 498 124 B
`web/models/clips/`**zero-draw, verified, not asserted** (`pipeline/glb_stat.py`):
every group file reports `tris=0 meshes=0 materials=0 images=0 nodes=66 draco=false`.
| group file | category | clips | bytes | motion |
|---|---|---|---:|---:|
| `idles.glb` | idle | 10 | 1 029 496 | 78.3 s |
| `social.glb` | social | 8 | 895 648 | 73.9 s |
| `venue.glb` | venue | 6 | 526 036 | 39.5 s |
| `browse.glb` | browse | 8 | 476 056 | 30.8 s |
| `sitlean.glb` | sitlean | 8 | 358 112 | 27.5 s |
| `locomotion.glb` | locomotion | 6 | 212 776 | 12.1 s |
| **total** | | **46** | **3 498 124 (3.34 MB)** | **262 s** |
For scale: the 8 clips PROCITY already ships (`web/models/peds/{idle,walk,sit,look,dance_*}.glb`)
cost **4.30 MB**. R41 adds **5.75× the clips for 78% of that** — and in **6 fetches, not 46**
(ruling 6). `web/assets/motion_manifest.json` is 16 KB.
### 3. Where the bytes went — three levers, all measured
Baseline: the 46 clips exported straight out of Blender as separate GLBs = **8.20 MB**.
| step | result | why it is safe |
|---|---|---:|
| rotation-only tracks | 8.08 → **7.71 MB** | `rigs.js` `_rotOnly` decodes every translation/scale track and throws it away on the same frame. They were dead bytes in this engine, not fidelity |
| accessor dedup by content | 7.71 → **5.98 MB** | lossless. 1 341 724 B of byte-identical keyframe payloads collapsed inside the group files |
| int16-normalized rotations | 5.98 → **3.34 MB** | glTF-legal animation output. three.js r175 de-normalises it in `GLTFLoader._getArrayFromAccessor` (verified in `web/vendor/`). **Worst measured angular error over all 46 clips: 0.0034°** |
Documented but **not applied**: `--drop-fingers` removes the 40 finger-bone channels, which are
**49.7% of the payload** (measured), taking the set to **1.71 MB**. Fidelity was kept instead —
the lever is one flag away if D's boot budget ever bites.
### 4. No retarget was run, and that is the correct call
The Mixamo bank and PROCITY's ped fleet are **the same mixamorig skeleton** — that is the entire
premise of the house "one canonical clip bank" law (`rigs.js` `_canon` folds `mixamorig1/4/…` to
`mixamorig`). Pushing a mixamorig→mixamorig pair through MIRPAMO's retargeter would add
transfer error for nothing **and** would emit the ped MESH into the clip file, destroying the
zero-draw property this round is affordable because of. MIRPAMO's retargeter is for foreign
skeletons (its CMU bonemap) and for rigging raw meshes.
MIRPAMO was still exercised as the tool ground-truth check the instruction asked for:
**`make smoke` PASSED** (Blender 5.1.2, exit 0, procedural mesh → autorig → retarget → verify).
The documented Blender-5 gotcha was respected by construction: nothing is exported through FBX at
any stage — Mixamo FBX goes **in**, glTF comes **out**, so slotted actions are never flattened.
### 5. Curation — three substitutions, all because the named clip was wrong for this town
§41.1's seed list was checked line by line against `index.json`. Three seeds resolved to something
the brief did not intend, and were substituted:
| seed named in §41.1 | what it actually is in the bank | substituted with |
|---|---|---|
| `45/90/135/180_Degree_{Left,Right}_Turn` (the whole set) | **rifle-aiming**: every one is "Rifle Turn" or "Rifle Crouch Turn". A shopping town does not need a citizen sweeping the street down a barrel | the civilian set — `Turning_90_Degrees_Left/Right` (Mixamo "Unarmed Turn 90"), `Turning_180_Degrees_While_Walking`, `Turning_In_Place`, `Walking_To_Standing_Idle` |
| `Examine_Forwards` / `Examine_Backwards` | **static poses**, not motions — 262 KB single-frame entries titled "Female Crouch Pose" / "Female Action Pose" | `Opening_Cabinet_Door` + `Picking_Up_An_Object_With_One_Hand` (real shop-floor motion) |
| `Legs_Crossed_Leaning_On_Object` | static pose, "Male Standing Pose", 262 KB | `Leaning_Square_Against_A_Wall_With_One_Leg_Up` + `Leaning_Square_With_Shoulders_Against_A_Wall` |
`2_People_Shaking_Hands_Part_1/2` **does** exist (as `…_-_Female` / `…_-_Male`) and the pair ships
together, flagged `"pair"` in the manifest — as do the two holding-turns and the cabinet open/close.
### 6. The proof, in the game's own three.js
`pipeline/clips_verify.py` (new, re-runnable, F may gate on it): **GREEN, 0 errors, 0 warnings**
sizes match the manifest, every clip id resolves to a named animation, nothing extra rides along,
every group is zero-draw and carries the same 66-node skeleton, every declared duration matches the
keyframe times in the bytes, and every clip survives `_rotOnly` with tracks left.
Loaded through **the repo's own vendored three.js r175 + GLTFLoader + SkeletonUtils**, bound to a
real `SkeletonUtils.clone` of `man_casual_01.glb`, all 46 clips: 65 raw tracks → **64 after
`_rotOnly`** (Hips.quaternion dropped, exactly as walk/idle behave), durations match to 3 decimals,
every sampled bone position finite and non-degenerate, 0 meshes and 0 triangles added to the scene.
**The R16 trap was checked for explicitly.** R16 found that keeping `Hips.quaternion` on a seated
clip lays the body flat. Spine tilt (Hips→Head vs +Y) was sampled at 24 points across every clip:
**0 frames over 75° on all 46**, worst clip 36.5° (`social_chat_water`, a lean on the watercooler),
seated family 2728° (a natural seated forward lean). Nothing folds.
**Loopability is measured, not asserted.** Every clip carries `loopSeamDeg` = the max per-bone angle
between its first and last rotation key. 28 of 46 are loopable; one curated-loopable clip was
**auto-demoted** by the packer — `turn_in_place`, 36.7° seam — and says so in the manifest.

View File

@ -166,3 +166,44 @@ to D's primitive (round-3 thin-wire lesson — spec allows "primitives when abse
`pipeline/_normalized/` (staging GLBs, live on depot), `pipeline/.genraw/` (raw skin gen).
Committed: the scripts, `*.md`, `batch.json`, `meshgod_batch.json`, `_audit_stats.json`,
`_normalize_results.json`, `web/assets/manifest.json`, `web/assets/thumbs/`, `docs/shots/laneE/`.
### 10. Motion library — `clips_export.py``clips_pack.py``clips_verify.py` *(round-41 §41.1: DONE, free, on-device)*
46 Mixamo clips → **6 grouped skeleton-only GLBs** in `web/models/clips/` (3 498 124 B, 0 tris) + a
**new** `web/assets/motion_manifest.json`. Source is ultra's `~/Documents/MOTIONLIB/green/mixamo_full/`
**only** (Mixamo = GREEN: usable in games, **no attribution**, never redistribute as clip packs).
The selection is checked in at `pipeline/clips_r41.json`**every asset by name, no wildcard**.
```bash
python3 - <<'EOF' > /tmp/fbx_list.txt # 1. the 46 names
import json;print('\n'.join(c['file'] for g in json.load(open('pipeline/clips_r41.json'))['groups'].values() for c in g['clips']))
EOF
mkdir -p /tmp/fbx && ssh johnking@100.91.239.7 \
"cd ~/Documents/MOTIONLIB/green/mixamo_full && tar czf - $(tr '\n' ' ' </tmp/fbx_list.txt)" | tar xzf - -C /tmp/fbx
BL=/Applications/Blender.app/Contents/MacOS/Blender # 2. FBX -> per-clip skeleton-only GLB
"$BL" --background --python pipeline/clips_export.py -- --list pipeline/clips_r41.json --fbx /tmp/fbx --out /tmp/clip_glb
python3 pipeline/clips_pack.py --stage /tmp/clip_glb \ # 3. merge to <=6 groups + manifest
--out web/models/clips --manifest web/assets/motion_manifest.json
python3 pipeline/clips_verify.py --stage /tmp/clip_glb # 4. gate: 0 errors = green
```
**No retarget is run and none is wanted:** the Mixamo bank and PROCITY's peds are the *same*
mixamorig skeleton (`rigs.js` `_canon` folds `mixamorig1/4/…`). A mixamorig→mixamorig pass through
MIRPAMO would add error **and** bake the ped mesh into the clip file, destroying the zero-draw
property. MIRPAMO (`~/Documents/MIRPAMO`, `make smoke` = its ground truth, green on Blender 5.1.2)
is for foreign skeletons (CMU/BVH) and for rigging raw meshes. **Blender-5 gotcha avoided by
construction: FBX goes in, glTF comes out — nothing is ever exported through FBX.**
Packer flags (each measured in `AUDIT.md` R41): `--tracks all` restores translation+scale (the
engine's `_rotOnly` discards them, so they ship off by default) · `--no-quantize` keeps float32
rotations (int16-normalized is ~2× smaller at a worst measured error of **0.0034°**) ·
`--drop-fingers` removes the 40 finger channels = **49.7% of the payload**, taking the set to
1.71 MB. Accessor dedup is lossless and always on (saved 1 341 724 B).
⚠️ **Licence zoning, ruling 1 (closed R41):** MIRPAMO's `library/downloads/` also held 3,077
**CC BY-NC** (never-shippable) Bandai-Namco BVH, unzoned and unmarked. They now live at
`library/downloads/red/bandai_namco_1_NC-research/` with the `_NC-research` suffix on **every** file
(so `mirpamo batch`'s `<rig>@<clip>.glb` naming propagates the marker), and MIRPAMO's
`library/manifest.json` licence label is reconciled to ultra's `MOTIONLIB/SOURCES.md`.
**R41 ships one licence regime: Mixamo.** CMU/100STYLE/ACCAD are CC-BY — they wait on 41.5's credits
surface.

View File

@ -6,33 +6,143 @@
"dj_phrtt_01.glb",
"dj_techno_01.glb",
"look.glb",
"procity_fit_aircon_unit_01.glb",
"procity_fit_arcade_cabinet_01.glb",
"procity_fit_ash_urn_01.glb",
"procity_fit_ashtray_post_01.glb",
"procity_fit_bain_marie_01.glb",
"procity_fit_banquette_01.glb",
"procity_fit_bass_guitar_01.glb",
"procity_fit_bass_guitar_01_hi.glb",
"procity_fit_beam_roof_01.glb",
"procity_fit_beer_umbrella_01.glb",
"procity_fit_bin_big_4x4_01.glb",
"procity_fit_bin_endoffunk_01.glb",
"procity_fit_bin_round_01.glb",
"procity_fit_bistro_table_01.glb",
"procity_fit_bookshelf_01.glb",
"procity_fit_box_crate_01.glb",
"procity_fit_broken_basin_01.glb",
"procity_fit_cable_snake_01.glb",
"procity_fit_can_stack_01.glb",
"procity_fit_cart_case_01.glb",
"procity_fit_cart_concorde_01.glb",
"procity_fit_cart_m447_01.glb",
"procity_fit_cart_stanton_01.glb",
"procity_fit_cdj_deck_01.glb",
"procity_fit_champ_bucket_01.glb",
"procity_fit_chiu_bin_01.glb",
"procity_fit_chiu_bin_half_01.glb",
"procity_fit_chiu_bin_low_01.glb",
"procity_fit_chiu_rack_long_01.glb",
"procity_fit_chiu_rack_short_01.glb",
"procity_fit_clothes_rack_01.glb",
"procity_fit_coffee_table_01.glb",
"procity_fit_column_square_01.glb",
"procity_fit_column_wide_01.glb",
"procity_fit_concrete_plinth_01.glb",
"procity_fit_counter_01.glb",
"procity_fit_counter_station_01.glb",
"procity_fit_counter_station_side_01.glb",
"procity_fit_counter_till_01.glb",
"procity_fit_crate_blue_01.glb",
"procity_fit_crate_stack_01.glb",
"procity_fit_crowd_barrier_01.glb",
"procity_fit_cube_shelf_wide_01.glb",
"procity_fit_deck_1200_01.glb",
"procity_fit_deck_1200_rigged_01.glb",
"procity_fit_distro_box_01.glb",
"procity_fit_door_shop_01.glb",
"procity_fit_drinks_fridge_01.glb",
"procity_fit_drum_808_01.glb",
"procity_fit_drum_kit_01.glb",
"procity_fit_drum_kit_01_hi.glb",
"procity_fit_drum_tr1000_01.glb",
"procity_fit_electric_guitar_01.glb",
"procity_fit_electric_guitar_01_hi.glb",
"procity_fit_fire_ext_01.glb",
"procity_fit_firepit_01.glb",
"procity_fit_flight_case_01.glb",
"procity_fit_glass_case_01.glb",
"procity_fit_guitar_amp_01.glb",
"procity_fit_guitar_amp_01_hi.glb",
"procity_fit_headphones_hd25_01.glb",
"procity_fit_headshell_technics_01.glb",
"procity_fit_jukebox_01.glb",
"procity_fit_keg_01.glb",
"procity_fit_library_trolley_01.glb",
"procity_fit_listening_booth_01.glb",
"procity_fit_lounge_chair_01.glb",
"procity_fit_magazine_rack_01.glb",
"procity_fit_marble_sink_01.glb",
"procity_fit_mic_stand_01.glb",
"procity_fit_mic_stand_01_hi.glb",
"procity_fit_milkshake_mixer_01.glb",
"procity_fit_mixer_mx2200_01.glb",
"procity_fit_mixer_pmc05pro_01.glb",
"procity_fit_mixer_pmc05pro_rigged_01.glb",
"procity_fit_mixer_pmc06pro_01.glb",
"procity_fit_mixer_pmc07pro_01.glb",
"procity_fit_mixer_pmc08pro_01.glb",
"procity_fit_mixer_pmc10_01.glb",
"procity_fit_mixer_pmc20_01.glb",
"procity_fit_mixer_pmx9000_01.glb",
"procity_fit_mixer_shdj1200_01.glb",
"procity_fit_mixer_shdj1200_rigged_01.glb",
"procity_fit_mixer_ttm54i_01.glb",
"procity_fit_mixer_ttm54i_rigged_01.glb",
"procity_fit_mos_bin_bottom_01.glb",
"procity_fit_mos_bin_lower_mid_01.glb",
"procity_fit_mos_bin_top_01.glb",
"procity_fit_mos_bin_upper_mid_01.glb",
"procity_fit_mos_stack_01.glb",
"procity_fit_old_couch_01.glb",
"procity_fit_pallet_01.glb",
"procity_fit_picnic_table_01.glb",
"procity_fit_pie_warmer_01.glb",
"procity_fit_planter_01.glb",
"procity_fit_plywood_bar_01.glb",
"procity_fit_pokie_01.glb",
"procity_fit_rack_bin_wooden_01.glb",
"procity_fit_raffle_drum_01.glb",
"procity_fit_record_crate_01.glb",
"procity_fit_rope_post_01.glb",
"procity_fit_sampler_mpc3000_01.glb",
"procity_fit_sampler_s900_01.glb",
"procity_fit_sampler_sp1200_01.glb",
"procity_fit_sandwich_board_01.glb",
"procity_fit_shelf_board_long_01.glb",
"procity_fit_shelf_board_short_01.glb",
"procity_fit_sl_deck_1_01.glb",
"procity_fit_sl_deck_2_01.glb",
"procity_fit_sl_deck_3_01.glb",
"procity_fit_sl_deck_4_01.glb",
"procity_fit_smoke_machine_01.glb",
"procity_fit_speaker_stack_01.glb",
"procity_fit_strobe_light_01.glb",
"procity_fit_sub_bass_01.glb",
"procity_fit_topiary_01.glb",
"procity_fit_tub_black_01.glb",
"procity_fit_tub_black_long_01.glb",
"procity_fit_tub_white_01.glb",
"procity_fit_tub_white_long_01.glb",
"procity_fit_tubs_3x3_01.glb",
"procity_fit_tubs_3x3_long_01.glb",
"procity_fit_tubs_4x2_vertical_01.glb",
"procity_fit_tubs_4x2_wide_01.glb",
"procity_fit_tubs_shelf_long_01.glb",
"procity_fit_tubs_shelf_wide_01.glb",
"procity_fit_urinal_trough_01.glb",
"procity_fit_vinyl_sleeve_01.glb",
"procity_fit_wall_shelves_big_01.glb",
"procity_fit_wall_shelves_long_01.glb",
"procity_fit_wheelie_bin_01.glb",
"procity_fit_window_booth_01.glb",
"procity_fit_wire_shelf_01.glb",
"procity_fit_wooden_rack_long_01.glb",
"procity_fit_wooden_rack_wide_01.glb",
"procity_fit_work_table_01.glb",
"procity_fit_zip_drive_01.glb",
"procity_street_aframe_board_01.glb",
"procity_street_bench_01.glb",
"procity_street_bench_01_hi.glb",

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,731 @@
{
"results": [
{
"src": "ashUrn.glb",
"id": "ash_urn",
"kind": "pub",
"file": "procity_fit_ash_urn_01.glb",
"tris_before": 198782,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.435,
"vertex_colour": true,
"footprint": [
0.32,
0.33
],
"height": 0.75,
"max_tex": 0,
"thumb": "thumbs/procity_fit_ash_urn_01.png"
},
{
"src": "ashtray.glb",
"id": "ashtray_post",
"kind": "pub",
"file": "procity_fit_ashtray_post_01.glb",
"tris_before": 198710,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.425,
"vertex_colour": true,
"footprint": [
0.41,
0.41
],
"height": 0.95,
"max_tex": 0,
"thumb": "thumbs/procity_fit_ashtray_post_01.png"
},
{
"src": "bainMarie.glb",
"id": "bain_marie",
"kind": "pub",
"file": "procity_fit_bain_marie_01.glb",
"tris_before": 184992,
"tris": 4958,
"draws": 1,
"nonmanifold": 0.835,
"vertex_colour": true,
"footprint": [
0.89,
0.76
],
"height": 0.95,
"max_tex": 0,
"thumb": "thumbs/procity_fit_bain_marie_01.png"
},
{
"src": "banquette.glb",
"id": "banquette",
"kind": "pub",
"file": "procity_fit_banquette_01.glb",
"tris_before": 190350,
"tris": 4999,
"draws": 1,
"nonmanifold": 0.686,
"vertex_colour": true,
"footprint": [
1.55,
1.02
],
"height": 0.85,
"max_tex": 0,
"thumb": "thumbs/procity_fit_banquette_01.png"
},
{
"src": "barrier.glb",
"id": "crowd_barrier",
"kind": "pub",
"file": "procity_fit_crowd_barrier_01.glb",
"tris_before": 189524,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.603,
"vertex_colour": true,
"footprint": [
1.97,
0.3
],
"height": 1.1,
"max_tex": 0,
"thumb": "thumbs/procity_fit_crowd_barrier_01.png"
},
{
"src": "beerUmbrella.glb",
"id": "beer_umbrella",
"kind": "pub",
"file": "procity_fit_beer_umbrella_01.glb",
"tris_before": 192752,
"tris": 4995,
"draws": 1,
"nonmanifold": 0.607,
"vertex_colour": true,
"footprint": [
2.68,
2.65
],
"height": 2.3,
"max_tex": 0,
"thumb": "thumbs/procity_fit_beer_umbrella_01.png"
},
{
"src": "binRound.glb",
"id": "bin_round",
"kind": "pub",
"file": "procity_fit_bin_round_01.glb",
"tris_before": 197576,
"tris": 4999,
"draws": 1,
"nonmanifold": 0.4,
"vertex_colour": true,
"footprint": [
0.53,
0.51
],
"height": 0.75,
"max_tex": 0,
"thumb": "thumbs/procity_fit_bin_round_01.png"
},
{
"src": "bistroTable.glb",
"id": "bistro_table",
"kind": "pub",
"file": "procity_fit_bistro_table_01.glb",
"tris_before": 196995,
"tris": 4999,
"draws": 1,
"nonmanifold": 0.382,
"vertex_colour": true,
"footprint": [
1.18,
1.18
],
"height": 0.92,
"max_tex": 0,
"thumb": "thumbs/procity_fit_bistro_table_01.png"
},
{
"src": "brokenBasin.glb",
"id": "broken_basin",
"kind": "pub",
"file": "procity_fit_broken_basin_01.glb",
"tris_before": 197978,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.535,
"vertex_colour": true,
"footprint": [
0.59,
0.49
],
"height": 0.6,
"max_tex": 0,
"thumb": "thumbs/procity_fit_broken_basin_01.png"
},
{
"src": "cableSnake.glb",
"id": "cable_snake",
"kind": "pub",
"file": "procity_fit_cable_snake_01.glb",
"tris_before": 193623,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.641,
"vertex_colour": true,
"footprint": [
0.45,
0.47
],
"height": 0.2,
"max_tex": 0,
"thumb": "thumbs/procity_fit_cable_snake_01.png"
},
{
"src": "canStack.glb",
"id": "can_stack",
"kind": "pub",
"file": "procity_fit_can_stack_01.glb",
"tris_before": 196005,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.464,
"vertex_colour": true,
"footprint": [
0.38,
0.32
],
"height": 0.45,
"max_tex": 0,
"thumb": "thumbs/procity_fit_can_stack_01.png"
},
{
"src": "cdj.glb",
"id": "cdj_deck",
"kind": "pub",
"file": "procity_fit_cdj_deck_01.glb",
"tris_before": 197967,
"tris": 4998,
"draws": 1,
"nonmanifold": 0.335,
"vertex_colour": true,
"footprint": [
0.63,
0.42
],
"height": 0.15,
"max_tex": 0,
"thumb": "thumbs/procity_fit_cdj_deck_01.png"
},
{
"src": "champBucket.glb",
"id": "champ_bucket",
"kind": "pub",
"file": "procity_fit_champ_bucket_01.glb",
"tris_before": 183943,
"tris": 4999,
"draws": 1,
"nonmanifold": 0.625,
"vertex_colour": true,
"footprint": [
0.24,
0.2
],
"height": 0.35,
"max_tex": 0,
"thumb": "thumbs/procity_fit_champ_bucket_01.png"
},
{
"src": "distroBox.glb",
"id": "distro_box",
"kind": "pub",
"file": "procity_fit_distro_box_01.glb",
"tris_before": 193633,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.415,
"vertex_colour": true,
"footprint": [
0.63,
0.87
],
"height": 1.2,
"max_tex": 0,
"thumb": "thumbs/procity_fit_distro_box_01.png"
},
{
"src": "fireExt.glb",
"id": "fire_ext",
"kind": "pub",
"file": "procity_fit_fire_ext_01.glb",
"tris_before": 198707,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.376,
"vertex_colour": true,
"footprint": [
0.34,
0.19
],
"height": 0.6,
"max_tex": 0,
"thumb": "thumbs/procity_fit_fire_ext_01.png"
},
{
"src": "firepit.glb",
"id": "firepit",
"kind": "pub",
"file": "procity_fit_firepit_01.glb",
"tris_before": 188041,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.883,
"vertex_colour": true,
"footprint": [
0.76,
0.56
],
"height": 0.5,
"max_tex": 0,
"thumb": "thumbs/procity_fit_firepit_01.png"
},
{
"src": "flightCase.glb",
"id": "flight_case",
"kind": "pub",
"file": "procity_fit_flight_case_01.glb",
"tris_before": 195452,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.376,
"vertex_colour": true,
"footprint": [
0.6,
0.36
],
"height": 0.75,
"max_tex": 0,
"thumb": "thumbs/procity_fit_flight_case_01.png"
},
{
"src": "jukebox.glb",
"id": "jukebox",
"kind": "pub",
"file": "procity_fit_jukebox_01.glb",
"tris_before": 184271,
"tris": 4999,
"draws": 1,
"nonmanifold": 0.754,
"vertex_colour": true,
"footprint": [
0.97,
0.41
],
"height": 1.52,
"max_tex": 0,
"thumb": "thumbs/procity_fit_jukebox_01.png"
},
{
"src": "keg.glb",
"id": "keg",
"kind": "pub",
"file": "procity_fit_keg_01.glb",
"tris_before": 441861,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.237,
"vertex_colour": true,
"footprint": [
0.4,
0.4
],
"height": 0.6,
"max_tex": 1024,
"thumb": "thumbs/procity_fit_keg_01.png"
},
{
"src": "loungeChair.glb",
"id": "lounge_chair",
"kind": "pub",
"file": "procity_fit_lounge_chair_01.glb",
"tris_before": 1319156,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.941,
"vertex_colour": true,
"footprint": [
0.69,
0.69
],
"height": 0.8,
"max_tex": 0,
"thumb": "thumbs/procity_fit_lounge_chair_01.png"
},
{
"src": "marbleSink.glb",
"id": "marble_sink",
"kind": "pub",
"file": "procity_fit_marble_sink_01.glb",
"tris_before": 199095,
"tris": 4998,
"draws": 1,
"nonmanifold": 0.212,
"vertex_colour": true,
"footprint": [
1.15,
0.56
],
"height": 0.85,
"max_tex": 0,
"thumb": "thumbs/procity_fit_marble_sink_01.png"
},
{
"src": "oldCouch.glb",
"id": "old_couch",
"kind": "pub",
"file": "procity_fit_old_couch_01.glb",
"tris_before": 196277,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.468,
"vertex_colour": true,
"footprint": [
1.8,
0.95
],
"height": 0.85,
"max_tex": 0,
"thumb": "thumbs/procity_fit_old_couch_01.png"
},
{
"src": "pallet.glb",
"id": "pallet",
"kind": "pub",
"file": "procity_fit_pallet_01.glb",
"tris_before": 198086,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.307,
"vertex_colour": true,
"footprint": [
0.79,
0.73
],
"height": 0.2,
"max_tex": 0,
"thumb": "thumbs/procity_fit_pallet_01.png"
},
{
"src": "picnicTable.glb",
"id": "picnic_table",
"kind": "pub",
"file": "procity_fit_picnic_table_01.glb",
"tris_before": 197370,
"tris": 4999,
"draws": 1,
"nonmanifold": 0.425,
"vertex_colour": true,
"footprint": [
1.38,
1.41
],
"height": 0.75,
"max_tex": 0,
"thumb": "thumbs/procity_fit_picnic_table_01.png"
},
{
"src": "pieWarmer.glb",
"id": "pie_warmer",
"kind": "pub",
"file": "procity_fit_pie_warmer_01.glb",
"tris_before": 196434,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.317,
"vertex_colour": true,
"footprint": [
0.61,
0.71
],
"height": 0.55,
"max_tex": 0,
"thumb": "thumbs/procity_fit_pie_warmer_01.png"
},
{
"src": "pillar.glb",
"id": "concrete_plinth",
"kind": "pub",
"file": "procity_fit_concrete_plinth_01.glb",
"tris_before": 190528,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.538,
"vertex_colour": true,
"footprint": [
0.76,
0.74
],
"height": 1.2,
"max_tex": 0,
"thumb": "thumbs/procity_fit_concrete_plinth_01.png"
},
{
"src": "planter.glb",
"id": "planter",
"kind": "pub",
"file": "procity_fit_planter_01.glb",
"tris_before": 239862,
"tris": 4897,
"draws": 1,
"nonmanifold": 0.877,
"vertex_colour": true,
"footprint": [
0.98,
0.96
],
"height": 0.55,
"max_tex": 0,
"thumb": "thumbs/procity_fit_planter_01.png"
},
{
"src": "plywoodBar.glb",
"id": "plywood_bar",
"kind": "pub",
"file": "procity_fit_plywood_bar_01.glb",
"tris_before": 192907,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.631,
"vertex_colour": true,
"footprint": [
1.51,
0.82
],
"height": 1.05,
"max_tex": 0,
"thumb": "thumbs/procity_fit_plywood_bar_01.png"
},
{
"src": "pokie.glb",
"id": "pokie",
"kind": "pub",
"file": "procity_fit_pokie_01.glb",
"tris_before": 198030,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.291,
"vertex_colour": true,
"footprint": [
0.75,
0.78
],
"height": 1.7,
"max_tex": 0,
"thumb": "thumbs/procity_fit_pokie_01.png"
},
{
"src": "raffleDrum.glb",
"id": "raffle_drum",
"kind": "pub",
"file": "procity_fit_raffle_drum_01.glb",
"tris_before": 193830,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.685,
"vertex_colour": true,
"footprint": [
0.37,
0.37
],
"height": 0.9,
"max_tex": 0,
"thumb": "thumbs/procity_fit_raffle_drum_01.png"
},
{
"src": "ropePost.glb",
"id": "rope_post",
"kind": "pub",
"file": "procity_fit_rope_post_01.glb",
"tris_before": 199748,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.276,
"vertex_colour": true,
"footprint": [
0.32,
0.32
],
"height": 1.0,
"max_tex": 0,
"thumb": "thumbs/procity_fit_rope_post_01.png"
},
{
"src": "sandwichBoard.glb",
"id": "sandwich_board",
"kind": "pub",
"file": "procity_fit_sandwich_board_01.glb",
"tris_before": 198359,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.257,
"vertex_colour": true,
"footprint": [
0.58,
0.56
],
"height": 1.0,
"max_tex": 0,
"thumb": "thumbs/procity_fit_sandwich_board_01.png"
},
{
"src": "smokeMachine.glb",
"id": "smoke_machine",
"kind": "pub",
"file": "procity_fit_smoke_machine_01.glb",
"tris_before": 194423,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.528,
"vertex_colour": true,
"footprint": [
0.17,
0.24
],
"height": 0.38,
"max_tex": 0,
"thumb": "thumbs/procity_fit_smoke_machine_01.png"
},
{
"src": "stackF1.glb",
"id": "speaker_stack",
"kind": "pub",
"file": "procity_fit_speaker_stack_01.glb",
"tris_before": 186357,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.599,
"vertex_colour": true,
"footprint": [
0.52,
0.52
],
"height": 1.8,
"max_tex": 0,
"thumb": "thumbs/procity_fit_speaker_stack_01.png"
},
{
"src": "strobe.glb",
"id": "strobe_light",
"kind": "pub",
"file": "procity_fit_strobe_light_01.glb",
"tris_before": 193392,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.572,
"vertex_colour": true,
"footprint": [
0.22,
0.24
],
"height": 0.4,
"max_tex": 0,
"thumb": "thumbs/procity_fit_strobe_light_01.png"
},
{
"src": "subBass.glb",
"id": "sub_bass",
"kind": "pub",
"file": "procity_fit_sub_bass_01.glb",
"tris_before": 193581,
"tris": 4998,
"draws": 1,
"nonmanifold": 0.531,
"vertex_colour": true,
"footprint": [
0.87,
0.63
],
"height": 0.7,
"max_tex": 0,
"thumb": "thumbs/procity_fit_sub_bass_01.png"
},
{
"src": "topiary.glb",
"id": "topiary",
"kind": "pub",
"file": "procity_fit_topiary_01.glb",
"tris_before": 415872,
"tris": 4999,
"draws": 1,
"nonmanifold": 0.937,
"vertex_colour": true,
"footprint": [
0.61,
0.61
],
"height": 1.2,
"max_tex": 0,
"thumb": "thumbs/procity_fit_topiary_01.png"
},
{
"src": "trough.glb",
"id": "urinal_trough",
"kind": "pub",
"file": "procity_fit_urinal_trough_01.glb",
"tris_before": 488808,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.065,
"vertex_colour": true,
"footprint": [
0.31,
0.26
],
"height": 0.7,
"max_tex": 1024,
"thumb": "thumbs/procity_fit_urinal_trough_01.png"
},
{
"src": "wheelieBin.glb",
"id": "wheelie_bin",
"kind": "pub",
"file": "procity_fit_wheelie_bin_01.glb",
"tris_before": 195084,
"tris": 5000,
"draws": 1,
"nonmanifold": 0.429,
"vertex_colour": true,
"footprint": [
0.72,
0.92
],
"height": 1.1,
"max_tex": 0,
"thumb": "thumbs/procity_fit_wheelie_bin_01.png"
}
],
"rejects": [
{
"id": "balustrade",
"why": "glass panel + handrail. The remesh lost the handrail entirely and left a lumpy slab with a detached lump \u2014 a transparent thin panel is the worst case for a signed-distance remesh. Eyeballed against balustrade_shot.png; unusable.",
"tris_src": 198229
},
{
"id": "cocktail_bar",
"why": "back-bar with a lit bottle display. At 5k the bottle shelving smears into a grey L-shaped mass; nothing about it reads as a bar. Rejected rather than shipping mush.",
"tris_src": 194298
},
{
"id": "dj_plinth",
"why": "the white plinth survives but the controller on top melts into a grey blob. Nothing is lost: cdj_deck normalized cleanly and Lane C can stand it on any counter or the plywood_bar.",
"tris_src": 197159
}
],
"skipped_staff": [
"staffBartender",
"staffChef",
"staffCleaner",
"staffDj",
"staffGlassie",
"staffSecurity"
]
}

View File

@ -0,0 +1,110 @@
procity_fit_aircon_unit_01.glb
procity_fit_ash_urn_01.glb
procity_fit_ashtray_post_01.glb
procity_fit_bain_marie_01.glb
procity_fit_banquette_01.glb
procity_fit_beam_roof_01.glb
procity_fit_beer_umbrella_01.glb
procity_fit_bin_big_4x4_01.glb
procity_fit_bin_endoffunk_01.glb
procity_fit_bin_round_01.glb
procity_fit_bistro_table_01.glb
procity_fit_broken_basin_01.glb
procity_fit_cable_snake_01.glb
procity_fit_can_stack_01.glb
procity_fit_cart_case_01.glb
procity_fit_cart_concorde_01.glb
procity_fit_cart_m447_01.glb
procity_fit_cart_stanton_01.glb
procity_fit_cdj_deck_01.glb
procity_fit_champ_bucket_01.glb
procity_fit_chiu_bin_01.glb
procity_fit_chiu_bin_half_01.glb
procity_fit_chiu_bin_low_01.glb
procity_fit_chiu_rack_long_01.glb
procity_fit_chiu_rack_short_01.glb
procity_fit_column_square_01.glb
procity_fit_column_wide_01.glb
procity_fit_concrete_plinth_01.glb
procity_fit_counter_station_01.glb
procity_fit_counter_station_side_01.glb
procity_fit_crate_blue_01.glb
procity_fit_crowd_barrier_01.glb
procity_fit_deck_1200_01.glb
procity_fit_deck_1200_rigged_01.glb
procity_fit_distro_box_01.glb
procity_fit_door_shop_01.glb
procity_fit_drum_808_01.glb
procity_fit_drum_tr1000_01.glb
procity_fit_fire_ext_01.glb
procity_fit_firepit_01.glb
procity_fit_flight_case_01.glb
procity_fit_headphones_hd25_01.glb
procity_fit_headshell_technics_01.glb
procity_fit_jukebox_01.glb
procity_fit_keg_01.glb
procity_fit_library_trolley_01.glb
procity_fit_lounge_chair_01.glb
procity_fit_marble_sink_01.glb
procity_fit_mixer_mx2200_01.glb
procity_fit_mixer_pmc05pro_01.glb
procity_fit_mixer_pmc05pro_rigged_01.glb
procity_fit_mixer_pmc06pro_01.glb
procity_fit_mixer_pmc07pro_01.glb
procity_fit_mixer_pmc08pro_01.glb
procity_fit_mixer_pmc10_01.glb
procity_fit_mixer_pmc20_01.glb
procity_fit_mixer_pmx9000_01.glb
procity_fit_mixer_shdj1200_01.glb
procity_fit_mixer_shdj1200_rigged_01.glb
procity_fit_mixer_ttm54i_01.glb
procity_fit_mixer_ttm54i_rigged_01.glb
procity_fit_mos_bin_bottom_01.glb
procity_fit_mos_bin_lower_mid_01.glb
procity_fit_mos_bin_top_01.glb
procity_fit_mos_bin_upper_mid_01.glb
procity_fit_mos_stack_01.glb
procity_fit_old_couch_01.glb
procity_fit_pallet_01.glb
procity_fit_picnic_table_01.glb
procity_fit_pie_warmer_01.glb
procity_fit_planter_01.glb
procity_fit_plywood_bar_01.glb
procity_fit_pokie_01.glb
procity_fit_rack_bin_wooden_01.glb
procity_fit_raffle_drum_01.glb
procity_fit_rope_post_01.glb
procity_fit_sampler_mpc3000_01.glb
procity_fit_sampler_s900_01.glb
procity_fit_sampler_sp1200_01.glb
procity_fit_sandwich_board_01.glb
procity_fit_shelf_board_long_01.glb
procity_fit_shelf_board_short_01.glb
procity_fit_sl_deck_1_01.glb
procity_fit_sl_deck_2_01.glb
procity_fit_sl_deck_3_01.glb
procity_fit_sl_deck_4_01.glb
procity_fit_smoke_machine_01.glb
procity_fit_speaker_stack_01.glb
procity_fit_strobe_light_01.glb
procity_fit_sub_bass_01.glb
procity_fit_topiary_01.glb
procity_fit_tub_black_01.glb
procity_fit_tub_black_long_01.glb
procity_fit_tub_white_01.glb
procity_fit_tub_white_long_01.glb
procity_fit_tubs_3x3_01.glb
procity_fit_tubs_3x3_long_01.glb
procity_fit_tubs_4x2_vertical_01.glb
procity_fit_tubs_4x2_wide_01.glb
procity_fit_tubs_shelf_long_01.glb
procity_fit_tubs_shelf_wide_01.glb
procity_fit_urinal_trough_01.glb
procity_fit_vinyl_sleeve_01.glb
procity_fit_wall_shelves_big_01.glb
procity_fit_wall_shelves_long_01.glb
procity_fit_wheelie_bin_01.glb
procity_fit_window_booth_01.glb
procity_fit_wooden_rack_long_01.glb
procity_fit_wooden_rack_wide_01.glb
procity_fit_zip_drive_01.glb

112
pipeline/_r41_sha1.json Normal file
View File

@ -0,0 +1,112 @@
{
"procity_fit_aircon_unit_01.glb": "ce874f7467673f91b81a14ecc60da23411517760",
"procity_fit_ash_urn_01.glb": "90205f27ff38968679384f4d04e18eb4315ede1d",
"procity_fit_ashtray_post_01.glb": "1bddccaf298394c68f5dae535c11532bab834ca9",
"procity_fit_bain_marie_01.glb": "d3482c49cc30dd47e7715e6d9c5340ab579d8f77",
"procity_fit_banquette_01.glb": "878ca932e557bc1a2201b315b8dc52d52a6a72b0",
"procity_fit_beam_roof_01.glb": "b856a9657443d765a0089cb3754464b09589a62f",
"procity_fit_beer_umbrella_01.glb": "c95b6106d5247d931d5c6895f30b135529135d3f",
"procity_fit_bin_big_4x4_01.glb": "7f1bfd7708bf6dfadcc67e31e056283d16c3916a",
"procity_fit_bin_endoffunk_01.glb": "ffac5eef693afb67ae51d29eed87f8279b68375c",
"procity_fit_bin_round_01.glb": "77bec352d4a8a0a90dfc070befcba2cb958b84ed",
"procity_fit_bistro_table_01.glb": "b68368b646c58c72520779e1f1b42206dba40282",
"procity_fit_broken_basin_01.glb": "bea1a0af949b0b847fe8849ec095f16ab241e8d1",
"procity_fit_cable_snake_01.glb": "aa6d2ba9374b141046038e1f35bed3d78fc3fd2d",
"procity_fit_can_stack_01.glb": "613b4edfde03bc23d4b0ded4be229883fd2536f3",
"procity_fit_cart_case_01.glb": "3aabbc174bc20bd81e9bd303b149e7ef4ba9e88a",
"procity_fit_cart_concorde_01.glb": "7d7420387b68045d5b2e1daf708a30af9300c54a",
"procity_fit_cart_m447_01.glb": "196f2fb283bce31d4170e006524e21c9a8c69c82",
"procity_fit_cart_stanton_01.glb": "19dfb2eaeee522215693bd11282eae6763b09f8c",
"procity_fit_cdj_deck_01.glb": "2f99eac26a4020a9906120c44a63d508b31a22fd",
"procity_fit_champ_bucket_01.glb": "e0526054c3bfed01b317ee4c6ce2b54e25cc54c6",
"procity_fit_chiu_bin_01.glb": "5d46826d073a864167fdfc8137527afc53962244",
"procity_fit_chiu_bin_half_01.glb": "ef0f25a0e335962ca82a691b2d54bed78061cfb9",
"procity_fit_chiu_bin_low_01.glb": "0d764172e91d81125a3c099e4deaf4cd44353d7b",
"procity_fit_chiu_rack_long_01.glb": "fc545f1f54475f51ef4bb15ca26857aab7442fe6",
"procity_fit_chiu_rack_short_01.glb": "7a891a7d3fa861dfd3e43784b2b51ea02d57adef",
"procity_fit_column_square_01.glb": "4e6d3e0e268aede54b09843696f8be714f3c0a75",
"procity_fit_column_wide_01.glb": "c62f08c030b76da90e806691944818e8e2d011ae",
"procity_fit_concrete_plinth_01.glb": "6048ad7408377e7ece105c651f8117739cba2c16",
"procity_fit_counter_station_01.glb": "f1bd34cb5212759778340e43db18c1d02c653b55",
"procity_fit_counter_station_side_01.glb": "78a472e3dd3467091449b9b9d02108f06ca36738",
"procity_fit_crate_blue_01.glb": "d0d0315df8b95fe0a455010415075b707c0cd959",
"procity_fit_crowd_barrier_01.glb": "363a0f80ce5239e616c5060634ff1bf8f03197d7",
"procity_fit_deck_1200_01.glb": "5070c5a85838164a8464a6c46a2ab08eb3fee70e",
"procity_fit_deck_1200_rigged_01.glb": "b2175d53dd8413d3aeca748ba6f036dbfb13e638",
"procity_fit_distro_box_01.glb": "69b0d169711702d21329dcb9641b7e395f6b7abf",
"procity_fit_door_shop_01.glb": "9abed4dda41f27252e35a150afcfb35d162e4418",
"procity_fit_drum_808_01.glb": "ee817b3e152b70afea1019917bcee09c9178eb76",
"procity_fit_drum_tr1000_01.glb": "4b6868395265325e0d9d3574e101313ff9823140",
"procity_fit_fire_ext_01.glb": "4471601a7ec882a93f6742afc1389a1e66652d8f",
"procity_fit_firepit_01.glb": "96bfd21bb94b4478f4c3cc18d0d8a99e328b09b2",
"procity_fit_flight_case_01.glb": "744378cd2cde5a3fad6b074b82f1a3fb0a2aa020",
"procity_fit_headphones_hd25_01.glb": "99c4c8e63fe59aac36bf6f9dd8907c7781b8a0d1",
"procity_fit_headshell_technics_01.glb": "1db6e8b04b44e9cb47c61b0bc49867d7f104ca5a",
"procity_fit_jukebox_01.glb": "c6efb9277dac136ce75e36c9c2f9b9085228c545",
"procity_fit_keg_01.glb": "7a05c10cfe335b3f14047d115f7e64b0518ab580",
"procity_fit_library_trolley_01.glb": "892f604c1660daa84bf10c699478c83ca3072473",
"procity_fit_lounge_chair_01.glb": "358e0f0624f0d211ece6afa2af1cad395770aa8b",
"procity_fit_marble_sink_01.glb": "c38245fc60e991499b59772cbb8ec519a8fa5c49",
"procity_fit_mixer_mx2200_01.glb": "32b231ec9fb305bfb1c5b82cc57cfdace32933d6",
"procity_fit_mixer_pmc05pro_01.glb": "f115b9caab36fd7401d76c5971c33ce27466364c",
"procity_fit_mixer_pmc05pro_rigged_01.glb": "2d3fe7173e7ecbfc99b7c4b9d7bd1840c3a2b006",
"procity_fit_mixer_pmc06pro_01.glb": "bb482cf1f4d09b62767f50bf65d9b39d595c6ebe",
"procity_fit_mixer_pmc07pro_01.glb": "a48aa36207fe3c1c124f19ba9f616193f3a79609",
"procity_fit_mixer_pmc08pro_01.glb": "b17d5c026d6c8450115177eaa9009400c1996f29",
"procity_fit_mixer_pmc10_01.glb": "f58e6b5d36cba3001a922ffee0f2b3f44a19e36f",
"procity_fit_mixer_pmc20_01.glb": "610876db83ef03ffe0441284cd7788ae17468836",
"procity_fit_mixer_pmx9000_01.glb": "0fca9258da288390861a10ac06ba613f8865cb44",
"procity_fit_mixer_shdj1200_01.glb": "0ecb07aab2616081e625dba2bc537348a544b837",
"procity_fit_mixer_shdj1200_rigged_01.glb": "daa49b9d6be0dc0b100bfdc5eddce47e49564275",
"procity_fit_mixer_ttm54i_01.glb": "c7f81962ce3702223ddbe3a117df8a1205b0bde5",
"procity_fit_mixer_ttm54i_rigged_01.glb": "181e6ec5f7edefb6b9f71616ce0bdf7eb064fd05",
"procity_fit_mos_bin_bottom_01.glb": "b3d032bc63296359424779091a6ddb64d25dd76c",
"procity_fit_mos_bin_lower_mid_01.glb": "e2714d078e56e8a6f19a956954ad534c20f67ee9",
"procity_fit_mos_bin_top_01.glb": "d92b4cf931d597a453dec68f800e50463ea21ea4",
"procity_fit_mos_bin_upper_mid_01.glb": "68a6533f072145bf28bec5ced388a09bec6e716d",
"procity_fit_mos_stack_01.glb": "64e9baba429910c504df14b94dd3acfeacccbf34",
"procity_fit_old_couch_01.glb": "549663b12ed46f3cec96d942a360d453667bdde9",
"procity_fit_pallet_01.glb": "fa4bd1c7e8ff3a7afee43afbf92983eed8040987",
"procity_fit_picnic_table_01.glb": "cea78b9c302a1df16d96cbe080a69e3f9b378984",
"procity_fit_pie_warmer_01.glb": "1aad547a6115f39c509389046be7a31855b0c710",
"procity_fit_planter_01.glb": "a00293155b8bf533aa0f70030f52f00e5d4bb6a5",
"procity_fit_plywood_bar_01.glb": "bccb7d15a3f61603e6b37a4a8633adacaeb5a611",
"procity_fit_pokie_01.glb": "90c2841ad1935a762929850edbdc4a4a5a9e7da6",
"procity_fit_rack_bin_wooden_01.glb": "c410601f91a32272172ec8e79c541f6373dbcaeb",
"procity_fit_raffle_drum_01.glb": "42c0b1d6a8c6c5c48a2ccc3d246e7504139eadf0",
"procity_fit_rope_post_01.glb": "ba4a0f6abc4058ca4632d4c23b99aa65cd4d99f5",
"procity_fit_sampler_mpc3000_01.glb": "8b3def767602846c9056a76d780db3732a48d845",
"procity_fit_sampler_s900_01.glb": "9714875be9af91f05c3ecf82631bbaf2fadf15f3",
"procity_fit_sampler_sp1200_01.glb": "370c9c3c177b38e194bfe5842e3d61febc36b91e",
"procity_fit_sandwich_board_01.glb": "cdf8467eb7f24ae2352ddfa6b68b17c2d34763b6",
"procity_fit_shelf_board_long_01.glb": "e46b3556a0be0648908e3297eb3ffdf3e1a66fc0",
"procity_fit_shelf_board_short_01.glb": "13826ee78d491d1072aa7a73d4e2f275ac2fe390",
"procity_fit_sl_deck_1_01.glb": "4d1e4e23f75711168d479784579d79c2349700bb",
"procity_fit_sl_deck_2_01.glb": "cd782ad484627ef2ed560c1564e9c409bb8a5d06",
"procity_fit_sl_deck_3_01.glb": "53391d94e52e6a4ddf84096dbbf8f54b6cc92bc3",
"procity_fit_sl_deck_4_01.glb": "52b7a1d28a76b5dda3d6eecb1f70eb92c59a6bc3",
"procity_fit_smoke_machine_01.glb": "ee952a30c9410ae10fc83cc4388b9f09770b6d2e",
"procity_fit_speaker_stack_01.glb": "e08303b63dc8a672d3dd32f6ef4d988a69a3e779",
"procity_fit_strobe_light_01.glb": "df345e87313b499bda5dec441aa553fb4ff39732",
"procity_fit_sub_bass_01.glb": "aa7508bf758e3e240ed51f5091082ce52cecbc96",
"procity_fit_topiary_01.glb": "99ae95024c6a01d01bab56e6151fbd0ef3a88e8b",
"procity_fit_tub_black_01.glb": "1e55e597e063aa454e8c359f0ab6bec5a08d7f09",
"procity_fit_tub_black_long_01.glb": "3f6c2935d267b5046f0517519e584f5e8eca3e41",
"procity_fit_tub_white_01.glb": "32fc23c3bcc35da595f3ccba997c50c1bed1b2ac",
"procity_fit_tub_white_long_01.glb": "380c9fed59141d95a7889ccdbabc7b4805a7fc88",
"procity_fit_tubs_3x3_01.glb": "72bec67884cf82dc8340382e8a538068691953ba",
"procity_fit_tubs_3x3_long_01.glb": "22efd2ba780593adc4bdeaf58ac6e1eae308f24e",
"procity_fit_tubs_4x2_vertical_01.glb": "be8d41e153dc57ec187bcd6b244f47ed7c78f99a",
"procity_fit_tubs_4x2_wide_01.glb": "770d6a6bc73953b3710a98821794bd3c74145e93",
"procity_fit_tubs_shelf_long_01.glb": "d15effbd8d3098c813cd024986002077c370aa0c",
"procity_fit_tubs_shelf_wide_01.glb": "e2cf1d1e633ccf4ee172a8c19a92531eacbe959c",
"procity_fit_urinal_trough_01.glb": "a857cbdaf157993dd822aa19042a0fb83c0d81b2",
"procity_fit_vinyl_sleeve_01.glb": "348c94af5f8363050ba8d516aa009cdcddb237d3",
"procity_fit_wall_shelves_big_01.glb": "6dda25529f7095b93e51f760b19a217d0b319432",
"procity_fit_wall_shelves_long_01.glb": "c77e3ce595746a07025dd2a8003552de527e8705",
"procity_fit_wheelie_bin_01.glb": "5826de87d818ecbce34190fa60829f6ef6ce937b",
"procity_fit_window_booth_01.glb": "9fe80b098955b5de24abfb6555fa86da80bf0a24",
"procity_fit_wooden_rack_long_01.glb": "4bef5d1069037c2267d04441d83b2680fd16d3a9",
"procity_fit_wooden_rack_wide_01.glb": "6757e16e33956ea430d3e29c1206ed940d1861fd",
"procity_fit_zip_drive_01.glb": "6b7fece8a4f646e693ec21ae6a1b94c792767da4"
}

View File

@ -28,6 +28,11 @@ R38_RESULTS = os.path.join(ROOT, "pipeline", "_r38_results.json") # roun
# sha1-verified off the public depot, so they are catalogued the same round they were generated
# (contrast R38, which held its results file at `.PENDING_PUBLISH` until the push landed).
R39_RESULTS = os.path.join(ROOT, "pipeline", "_r39_results.json") # round-39 arcade kit
# [R41 §41.2] the three imported libraries: ~/Documents/3dstore (shop fittings),
# ~/Documents/not-tonight/art_incoming (pub/venue props), ultra's 3D=models/dj-gear (DJ gear).
R41_3DSTORE = os.path.join(ROOT, "pipeline", "_r41_3dstore_results.json")
R41_PUB = os.path.join(ROOT, "pipeline", "_r41_pub_results.json")
R41_DJGEAR = os.path.join(ROOT, "pipeline", "_r41_djgear_results.json")
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
AUDIO = os.path.join(ROOT, "web", "assets", "audio") # round-11 audio pack
DEPOT = "https://digalot.fyi/3god"
@ -312,6 +317,35 @@ def build():
"thumb": "thumbs/" + r["file"].replace(".glb", ".png"),
}
(furniture if r["file"].startswith("procity_street_") else fittings)[fid] = entry
# ---- R41 §41.2: the three imported libraries ------------------------------------------------
# These carry their own measured footprint/height (taken from the SHIPPED GLB, not from the
# Blender scene that wrote it — rig_join's in-scene bounds mis-reported a 0.114 m mixer as
# 0.629 m, and Lane C places on these numbers). They also carry the two facts C needs to spend
# the 162-draw interior margin deliberately: `draws` and `tris` per asset.
for results_file in (R41_3DSTORE, R41_PUB, R41_DJGEAR):
if not os.path.exists(results_file):
continue
for r in json.load(open(results_file)).get("results", []):
fid = r["file"].replace("procity_fit_", "").replace("_01.glb", "")
entry = {
"file": r["file"],
"footprint": r["footprint"],
"height": r["height"],
"thumb": r["thumb"],
"kind": r.get("kind"),
"tris": r.get("tris"),
"draws": r.get("draws", r.get("draws_after", 1)),
"source": r.get("src"),
}
if r.get("kind") == "djgear":
# what C/D need to drive it later: which contract nodes actually ship, and the
# full control-name list the source carried (dj-gear/manifest.json).
entry["mode"] = r.get("mode")
entry["control_nodes"] = r.get("contract_shipped", [])
entry["source_controls"] = r.get("named_controls", [])
entry["drivable"] = bool(r.get("contract_shipped"))
fittings[fid] = entry
# depot-reuse assets (no local normalize) — e.g. the reused vintage cash register
for fid, meta in DEPOT_REUSE.items():
fittings.setdefault(fid, dict(meta))

View File

@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Assert the DJ-gear named-node contract survives normalization — read from the GLB, not Blender.
python3 pipeline/check_contract.py SRC_DIR OUT_DIR MAP.json
python3 pipeline/check_contract.py --list FILE.glb [FILE.glb ...]
The contract 90sDJsim's GEAR driver reads (dj-gear/README.md) is five bare node names:
Platter_SPIN · Arm_YAW · Fader_PITCH · Fader_CROSS · Btn_STARTSTOP
Integrator ruling 5 exists because `gltf-transform optimize` silently deleted `Fader_CROSS` from a
696-node mixer. "Silently" is the operative word, so this check parses the **shipped GLB's** node
list rather than asking the tool that produced it a scene-graph scan inside Blender still sees
control EMPTIES that were never exported, and would have reported a contract that the file does
not actually carry.
Verdict per model: PASS = every contract node present in the source is present in the output.
FAIL = one or more were lost (the ruling-5 failure mode).
N/A = the source carried none (a cartridge has no crossfader).
Exit 1 if any FAIL.
"""
import json, os, struct, sys
CONTRACT = ["Platter_SPIN", "Arm_YAW", "Fader_PITCH", "Fader_CROSS", "Btn_STARTSTOP"]
def glb_nodes(path):
"""Node names from a GLB's JSON chunk. Dependency-free — no Blender, no pygltflib."""
d = open(path, "rb").read()
if d[:4] != b"glTF":
raise ValueError("not a GLB")
off = 12
while off + 8 <= len(d):
clen, ctype = struct.unpack("<II", d[off:off + 8])
if ctype == 0x4E4F534A:
g = json.loads(d[off + 8: off + 8 + clen].decode("utf-8"))
return [n.get("name", "") for n in g.get("nodes", [])]
off += 8 + clen
raise ValueError("no JSON chunk")
def contract_of(path):
names = set(glb_nodes(path))
return [c for c in CONTRACT if c in names]
def main():
if "--list" in sys.argv:
for f in sys.argv[sys.argv.index("--list") + 1:]:
print(f"{os.path.basename(f):46} {','.join(contract_of(f)) or '-'}")
return 0
if len(sys.argv) < 4:
print(__doc__)
return 2
src_dir, out_dir, mapfile = sys.argv[1], sys.argv[2], sys.argv[3]
pairs = json.load(open(mapfile)) # [{"src":"TTM54i.glb","file":"procity_...glb","mode":...}]
rows, fails = [], 0
for p in pairs:
s = os.path.join(src_dir, p["src"])
o = os.path.join(out_dir, p["file"])
if not (os.path.isfile(s) and os.path.isfile(o)):
rows.append({**p, "verdict": "MISSING"})
fails += 1
continue
before, after = contract_of(s), contract_of(o)
if not before:
verdict = "N/A"
elif p.get("mode") == "static":
# a static dressing copy is one joined mesh BY DESIGN — it is not claimed to be
# drivable, so a missing contract here is expected, not a failure.
verdict = "STATIC (contract intentionally dropped)" if not after else "STATIC"
elif set(before) <= set(after):
verdict = "PASS"
else:
verdict = "FAIL"
fails += 1
rows.append({**p, "contract_before": before, "contract_after": after, "verdict": verdict})
w = max(len(r["file"]) for r in rows) if rows else 40
print(f"{'shipped file':{w}} {'mode':7} {'source contract':46} {'shipped contract':46} verdict")
print("-" * (w + 110))
for r in rows:
print(f"{r['file']:{w}} {r.get('mode',''):7} "
f"{','.join(r.get('contract_before', [])) or '-':46} "
f"{','.join(r.get('contract_after', [])) or '-':46} {r['verdict']}")
n_pass = sum(1 for r in rows if r["verdict"] == "PASS")
n_na = sum(1 for r in rows if r["verdict"] == "N/A")
n_st = sum(1 for r in rows if str(r["verdict"]).startswith("STATIC"))
print(f"\nPASS {n_pass} · STATIC {n_st} · N/A {n_na} · FAIL {fails}")
json.dump(rows, open(os.path.join(out_dir, "_contract_check.json"), "w"), indent=2)
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())

110
pipeline/clips_export.py Normal file
View File

@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""PROCITY R41 §41.1 — stage 1 of 2: Mixamo anim-only FBX -> skeleton-only per-clip GLB.
Runs INSIDE Blender:
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/clips_export.py -- \
--list pipeline/clips_r41.json --fbx <dir of the named FBX> --out <staging dir>
Stage 2 is `pipeline/clips_pack.py` (pure python, no Blender): it merges the per-clip GLBs into
the <=6 grouped GLBs under web/models/clips/ and writes web/assets/motion_manifest.json.
WHY TWO STAGES. MIRPAMO's roadmap item "multi-clip single-GLB export" is not built yet, and the
documented Blender-5 slotted-action hazard (MIRPAMO/CLAUDE.md) bites exactly when you try to move
actions between armatures inside one .blend. Exporting one clip per GLB is the proven path it is
how web/models/peds/sit.glb and look.glb were made (generator "Khronos glTF Blender I/O v5.1.20",
66 nodes, 0 meshes) and the merge is then a deterministic glTF-level operation with no Blender
version risk at all.
NO RETARGET IS NEEDED and none is performed: the Mixamo bank and PROCITY's ped fleet are the SAME
mixamorig skeleton (rigs.js `_canon` folds mixamorig1/4/... -> mixamorig), which is the whole point
of the house "one canonical clip bank" law. MIRPAMO's retargeter is for foreign skeletons (CMU/BVH)
and for rigging raw meshes; pointing it at a mixamorig->mixamorig pair would only add error, and it
would emit the ped MESH into the clip file, breaking the zero-draw property that makes this round
affordable. `make smoke` is still run as the tool ground-truth check (see E-progress.md).
"""
import json
import os
import sys
import bpy
def argv_after_ddash():
return sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
def parse_args(a):
out = {}
i = 0
while i < len(a):
out[a[i].lstrip("-")] = a[i + 1]
i += 2
return out
def reset():
bpy.ops.wm.read_factory_settings(use_empty=True)
def import_clip(path):
bpy.ops.import_scene.fbx(filepath=path, automatic_bone_orientation=False,
ignore_leaf_bones=False)
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
if len(arms) != 1:
raise RuntimeError("expected exactly 1 armature, got %d in %s" % (len(arms), path))
return arms[0]
def export_glb(arm, out_path, anim_name):
# name the action == the clip id so the exported animation carries it
if arm.animation_data and arm.animation_data.action:
arm.animation_data.action.name = anim_name
for o in bpy.data.objects:
o.select_set(o is arm)
bpy.context.view_layer.objects.active = arm
kw = dict(filepath=out_path, export_format="GLB",
export_animations=True, export_animation_mode="ACTIVE_ACTIONS",
export_skins=True, export_yup=True,
export_materials="NONE", export_texcoords=False, export_normals=False,
export_apply=False, use_selection=True)
try:
bpy.ops.export_scene.gltf(**kw)
except TypeError: # older/newer exporter arg drift — retry minimal
bpy.ops.export_scene.gltf(filepath=out_path, export_format="GLB",
export_animations=True, use_selection=True)
def main():
a = parse_args(argv_after_ddash())
sel = json.load(open(a["list"]))
fbx_dir, out_dir = a["fbx"], a["out"]
os.makedirs(out_dir, exist_ok=True)
results, fails = [], []
for gname, g in sel["groups"].items():
for c in g["clips"]:
src = os.path.join(fbx_dir, c["file"])
dst = os.path.join(out_dir, "%s.glb" % c["id"])
try:
reset()
arm = import_clip(src)
nbones = len(arm.data.bones)
act = arm.animation_data.action if arm.animation_data else None
fs, fe = (act.frame_range if act else (0, 0))
export_glb(arm, dst, c["id"])
results.append(dict(id=c["id"], group=gname, file=c["file"], bones=nbones,
frame_start=float(fs), frame_end=float(fe),
fps=bpy.context.scene.render.fps,
bytes=os.path.getsize(dst)))
print("[ok ] %-22s %-64s bones=%d frames=%.0f..%.0f -> %d B"
% (c["id"], c["file"], nbones, fs, fe, os.path.getsize(dst)))
except Exception as e: # noqa: BLE001
fails.append(dict(id=c["id"], file=c["file"], err=repr(e)))
print("[FAIL] %-22s %s: %r" % (c["id"], c["file"], e))
with open(os.path.join(out_dir, "_export_results.json"), "w") as f:
json.dump(dict(ok=results, fail=fails), f, indent=1)
print("\n%d exported / %d failed" % (len(results), len(fails)))
main()

329
pipeline/clips_pack.py Normal file
View File

@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""PROCITY R41 §41.1 — stage 2 of 2: per-clip skeleton-only GLBs -> <=6 GROUPED clip GLBs.
python3 pipeline/clips_pack.py --stage <dir from clips_export.py> \
--list pipeline/clips_r41.json --out web/models/clips \
--manifest web/assets/motion_manifest.json
Pure python3 stdlib no Blender, no deps. Operates at the glTF level, which is safe here because
every per-clip GLB in the staging dir carries the IDENTICAL 66-node mixamorig skeleton (asserted,
not assumed: `--check` fails loudly on any node-name mismatch). Merging is therefore: keep group
clip #1's node graph + skin verbatim, then append every other clip's animation with its channel
targets remapped by node NAME.
TRACK FILTER (`--tracks`, default `rot`).
PROCITY's consumer is `web/js/citizens/rigs.js` `_rotOnly`, which keeps ONLY `.quaternion` tracks and
additionally drops `Hips.quaternion` every translation and scale track a clip GLB ships is decoded
by three.js and then thrown away on the same frame. Shipping them is pure boot cost. So the default
packs rotation channels only. `--tracks all` reproduces the unfiltered T/R/S file if a future round
needs root motion back (the pipeline is re-runnable from `clips_r41.json` in ~1 minute).
LOOP SEAM is measured, not asserted: for every clip we compute the maximum per-bone angle between
the first and last rotation keyframe (`loopSeamDeg`). Small = the clip cycles cleanly and can be
played on repeat; large = it is a one-shot and should be played once and blended out. The manifest
carries both the curated `loopable` flag and the measured number so Lane D can second-guess it.
"""
import argparse
import json
import math
import os
import re
import struct
COMP = {5120: ("b", 1), 5121: ("B", 1), 5122: ("h", 2), 5123: ("H", 2),
5125: ("I", 4), 5126: ("f", 4)}
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
# ---------------------------------------------------------------- GLB read/write
def glb_read(path):
with open(path, "rb") as f:
data = f.read()
if data[:4] != b"glTF":
raise ValueError("%s: not a GLB" % path)
js, bin_ = None, b""
off = 12
while off < len(data):
ln, ty = struct.unpack_from("<II", data, off)
off += 8
if ty == 0x4E4F534A:
js = json.loads(data[off:off + ln])
elif ty == 0x004E4942:
bin_ = data[off:off + ln]
off += ln
return js, bin_
def glb_write(path, js, bin_):
j = json.dumps(js, separators=(",", ":")).encode()
j += b" " * ((4 - len(j) % 4) % 4)
b = bytes(bin_) + b"\0" * ((4 - len(bin_) % 4) % 4)
total = 12 + 8 + len(j) + (8 + len(b) if b else 0)
out = bytearray()
out += struct.pack("<4sII", b"glTF", 2, total)
out += struct.pack("<II", len(j), 0x4E4F534A) + j
if b:
out += struct.pack("<II", len(b), 0x004E4942) + b
with open(path, "wb") as f:
f.write(out)
return total
def acc_bytes(js, bin_, ai):
"""Return (tightly packed bytes, count, type, componentType, normalized) for accessor ai."""
a = js["accessors"][ai]
ct, n = a["componentType"], a["count"]
fmt, sz = COMP[ct]
nc = NCOMP[a["type"]]
elem = sz * nc
bv = js["bufferViews"][a["bufferView"]]
base = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
stride = bv.get("byteStride") or elem
if stride == elem:
raw = bin_[base:base + elem * n]
else:
raw = b"".join(bin_[base + i * stride: base + i * stride + elem] for i in range(n))
return raw, n, a["type"], ct, a.get("normalized", False), a.get("min"), a.get("max")
def acc_floats(js, bin_, ai):
raw, n, ty, ct, _, _, _ = acc_bytes(js, bin_, ai)
fmt, sz = COMP[ct]
nc = NCOMP[ty]
v = struct.unpack("<%d%s" % (n * nc, fmt), raw)
return [v[i * nc:(i + 1) * nc] for i in range(n)]
def quantize_rot(raw, count, ct):
"""float32 VEC4 quaternions -> int16 normalized VEC4 (glTF-legal animation output).
Halves the dominant payload. glTF 2.0 dequantises SHORT as max(c/32767, -1.0) and three.js
r175 does exactly that in GLTFLoader `_getArrayFromAccessor` (verified in
web/vendor/addons/loaders/GLTFLoader.js). Worst-case angular error is ~0.006 deg three
orders of magnitude under anything a 1.7 m citizen can show on screen.
"""
if ct != 5126:
return raw, ct, False
v = struct.unpack("<%df" % (count * 4), raw)
q = [max(-32767, min(32767, int(round(max(-1.0, min(1.0, x)) * 32767.0)))) for x in v]
return struct.pack("<%dh" % len(q), *q), 5122, True
class Sink:
"""Accumulates tightly packed accessor payloads into one BIN chunk, deduped by content."""
def __init__(self):
self.bin = bytearray()
self.views = []
self.accs = []
self._seen = {}
self.dedup_saved = 0
def add(self, raw, count, ty, ct, normalized, mn, mx):
key = (bytes(raw), count, ty, ct, normalized)
if key in self._seen: # identical payload already in this file
self.dedup_saved += len(raw)
return self._seen[key]
while len(self.bin) % 4:
self.bin += b"\0"
off = len(self.bin)
self.bin += raw
self.views.append({"buffer": 0, "byteOffset": off, "byteLength": len(raw)})
a = {"bufferView": len(self.views) - 1, "componentType": ct,
"count": count, "type": ty}
if normalized:
a["normalized"] = True
if mn is not None:
a["min"], a["max"] = mn, mx
self.accs.append(a)
self._seen[key] = len(self.accs) - 1
return len(self.accs) - 1
# The bank is not uniform about the mixamorig namespace: 45 of the 46 R41 clips export as
# `mixamorig1:Bone`, one (Idle_Smoking) as `mixamorig:Bone` — the exact split the house `_canon`
# trick in rigs.js exists to absorb (and the same split already shipped: idle.glb is mixamorig4:,
# sit.glb is mixamorig:). Joint NAMES and node ORDER are identical across all 46 (asserted below).
# Normalise to the bare `mixamorig:` namespace so the merge is name-exact and every group file
# reads the same; three.js sanitises the colon out anyway, so runtime binding is unchanged.
_NS = re.compile(r"mixamorig\d*:")
def canon_name(n):
return _NS.sub("mixamorig:", n) if n else n
def quat_angle_deg(q0, q1):
d = abs(sum(a * b for a, b in zip(q0, q1)))
return math.degrees(2.0 * math.acos(max(-1.0, min(1.0, d))))
# ---------------------------------------------------------------- pack one group
FINGER = re.compile(r"Hand(Thumb|Index|Middle|Ring|Pinky)\d")
def pack_group(gname, clips, stage, out_dir, tracks, quant=True, drop_fingers=False):
keep = {"rotation"} if tracks == "rot" else {"rotation", "translation", "scale"}
base_js, base_bin = glb_read(os.path.join(stage, "%s.glb" % clips[0]["id"]))
for n in base_js["nodes"]:
if n.get("name"):
n["name"] = canon_name(n["name"])
names = [n.get("name") for n in base_js["nodes"]]
if len(set(names)) != len(names):
raise SystemExit("%s: duplicate node names in base skeleton" % gname)
idx_of = {n: i for i, n in enumerate(names)}
sink = Sink()
out = {
"asset": {"version": "2.0",
"generator": "PROCITY pipeline/clips_pack.py (R41 41.1) from %s"
% base_js["asset"].get("generator", "?")},
"scene": base_js.get("scene", 0),
"scenes": base_js["scenes"],
"nodes": base_js["nodes"],
"animations": [],
"buffers": [{"byteLength": 0}],
}
if "skins" in base_js:
skins = json.loads(json.dumps(base_js["skins"]))
for sk in skins:
if "inverseBindMatrices" in sk:
sk["inverseBindMatrices"] = sink.add(*acc_bytes(base_js, base_bin,
sk["inverseBindMatrices"]))
out["skins"] = skins
report = []
for c in clips:
p = os.path.join(stage, "%s.glb" % c["id"])
js, bin_ = glb_read(p)
nm = [canon_name(n.get("name")) for n in js["nodes"]]
if nm != names:
raise SystemExit("%s/%s: skeleton mismatch vs group base (%d vs %d nodes)"
% (gname, c["id"], len(nm), len(names)))
if len(js.get("animations", [])) != 1:
raise SystemExit("%s/%s: expected 1 animation, got %d"
% (gname, c["id"], len(js.get("animations", []))))
anim = js["animations"][0]
smap, chans, samps = {}, [], []
dur, seam, nrot = 0.0, 0.0, 0
for ch in anim["channels"]:
path = ch["target"]["path"]
if path not in keep or "node" not in ch["target"]:
continue
tname = nm[ch["target"]["node"]]
if drop_fingers and FINGER.search(tname):
continue
si = ch["sampler"]
if si not in smap:
s = anim["samplers"][si]
t = acc_floats(js, bin_, s["input"])
dur = max(dur, t[-1][0] if t else 0.0)
oraw, on, oty, oct, onorm, omn, omx = acc_bytes(js, bin_, s["output"])
if path == "rotation":
q = acc_floats(js, bin_, s["output"])
if len(q) > 1:
seam = max(seam, quat_angle_deg(q[0], q[-1]))
nrot += 1
if quant and s.get("interpolation", "LINEAR") != "CUBICSPLINE":
oraw, oct, onorm = quantize_rot(oraw, on, oct)
omn = omx = None
smap[si] = len(samps)
samps.append({"input": sink.add(*acc_bytes(js, bin_, s["input"])),
"output": sink.add(oraw, on, oty, oct, onorm, omn, omx),
"interpolation": s.get("interpolation", "LINEAR")})
chans.append({"sampler": smap[si],
"target": {"node": idx_of[tname], "path": path}})
out["animations"].append({"name": c["id"], "channels": chans, "samplers": samps})
report.append(dict(id=c["id"], duration=round(dur, 4), channels=len(chans),
samplers=len(samps), rot_bones=nrot, loopSeamDeg=round(seam, 2)))
out["bufferViews"] = sink.views
out["accessors"] = sink.accs
out["buffers"][0]["byteLength"] = len(sink.bin) + ((4 - len(sink.bin) % 4) % 4)
os.makedirs(out_dir, exist_ok=True)
path = os.path.join(out_dir, "%s.glb" % gname)
size = glb_write(path, out, sink.bin)
return path, size, report, len(names), sink.dedup_saved
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--stage", required=True)
ap.add_argument("--list", default="pipeline/clips_r41.json")
ap.add_argument("--out", default="web/models/clips")
ap.add_argument("--manifest", default="web/assets/motion_manifest.json")
ap.add_argument("--tracks", choices=["rot", "all"], default="rot")
ap.add_argument("--no-quantize", action="store_true",
help="keep float32 rotation outputs (default: int16 normalized, ~2x smaller)")
ap.add_argument("--drop-fingers", action="store_true",
help="drop the 40 finger-bone channels (~half the payload; hands then hold "
"their bind pose). Off by default — fidelity kept, lever documented.")
ap.add_argument("--loop-seam-max", type=float, default=25.0,
help="a clip curated loopable but seaming worse than this is demoted")
a = ap.parse_args()
sel = json.load(open(a.list))
man = {
"_schema": ("PROCITY motion manifest v1 (R41 41.1). groups{<file>} -> one GLB under "
"web/models/clips/, holding N named animations. clips{<clipId>} -> "
"{group,category,duration,loopable,source,mixamo,pair?}. Load a group ONCE "
"(gltf.animations[] are named by clipId); index by clips[id].group."),
"_licence": ("Mixamo (Adobe) via ultra ~/Documents/MOTIONLIB/green/mixamo_full/. GREEN: "
"free to use inside games/projects, NO attribution required. Never "
"redistribute as a clip pack. R41 ships this licence regime only (ruling 2)."),
"_tracks": ("rotation-only" if a.tracks == "rot" else "translation+rotation+scale"),
"_encoding": ("rotation outputs are %s; %s"
% ("float32" if a.no_quantize else "int16 NORMALIZED (glTF-legal, three.js "
"r175 de-normalises in GLTFLoader._getArrayFromAccessor)",
"finger channels DROPPED" if a.drop_fingers else
"all 65 joints animated incl. fingers")),
"_note": ("Skeleton-only, 0 tris, 66 nodes (65 mixamorig joints + Armature) — the same "
"byte-shape as web/models/peds/idle.glb. rigs.js `_canon` folds the mixamorigN: "
"prefix, `_rotOnly` keeps quaternions and drops Hips.quaternion."),
"fps": 30,
"groups": {},
"clips": {},
}
total, dedup = 0, 0
for gname, g in sel["groups"].items():
path, size, rep, nnodes, dd = pack_group(gname, g["clips"], a.stage, a.out, a.tracks,
quant=not a.no_quantize,
drop_fingers=a.drop_fingers)
total += size
dedup += dd
byid = {r["id"]: r for r in rep}
man["groups"]["%s.glb" % gname] = {
"category": g["category"], "desc": g["desc"], "clips": [c["id"] for c in g["clips"]],
"bytes": size, "nodes": nnodes,
"durationSum": round(sum(r["duration"] for r in rep), 3),
}
for c in g["clips"]:
r = byid[c["id"]]
loop = bool(c.get("loop")) and r["loopSeamDeg"] <= a.loop_seam_max
e = {"group": "%s.glb" % gname, "category": g["category"],
"duration": r["duration"], "loopable": loop,
"loopSeamDeg": r["loopSeamDeg"],
"source": c["file"], "mixamo": c["mixamo"],
"channels": r["channels"], "rotBones": r["rot_bones"]}
if c.get("loop") and not loop:
e["loopDemoted"] = "curated loopable but loop seam %.1f deg > %.1f" % (
r["loopSeamDeg"], a.loop_seam_max)
if c.get("pair"):
e["pair"] = c["pair"]
man["clips"][c["id"]] = e
print("[pack] %-11s %2d clips %7d B seam max %.1f deg"
% (gname, len(rep), size, max(r["loopSeamDeg"] for r in rep)))
man["totalBytes"] = total
man["clipCount"] = len(man["clips"])
man["groupCount"] = len(man["groups"])
man["dedupSavedBytes"] = dedup
os.makedirs(os.path.dirname(a.manifest), exist_ok=True)
with open(a.manifest, "w") as f:
json.dump(man, f, indent=1)
f.write("\n")
print("\n%d clips in %d group GLBs, %d B total (%.2f MB) -> %s"
% (man["clipCount"], man["groupCount"], total, total / 1048576.0, a.out))
if __name__ == "__main__":
main()

89
pipeline/clips_r41.json Normal file
View File

@ -0,0 +1,89 @@
{
"_comment": "PROCITY R41 §41.1 motion-library selection. SOURCE: ultra ~/Documents/MOTIONLIB/green/mixamo_full/ ONLY (Mixamo/Adobe, GREEN zone: free to use inside games, no attribution required, never redistribute as clip packs). Ruling 2: one licence regime this round. Every entry names an exact file in that bank's index.json — no wildcards (ruling 3).",
"_source_dir": "johnking@100.91.239.7:~/Documents/MOTIONLIB/green/mixamo_full/",
"_group_files": "web/models/clips/<group>.glb — 6 groups, one fetch each (ruling 6)",
"groups": {
"idles": {
"category": "idle",
"desc": "the clone-army killer: 10 distinct standing idles",
"clips": [
{"id": "idle_breathing", "file": "Breathing_Idle__c9c6d0d5.fbx", "mixamo": "Breathing Idle", "loop": true},
{"id": "idle_standing", "file": "Standing_Idle__c9c831cf.fbx", "mixamo": "Standing Idle", "loop": true},
{"id": "idle_look_around", "file": "Idle_Stand_Looking_Around__c9c6cdf7.fbx", "mixamo": "Looking Around", "loop": true},
{"id": "idle_weight_shift", "file": "Shifting_Weight_From_Side_To_Side__c9c908f7.fbx","mixamo": "Weight Shift", "loop": true},
{"id": "idle_cocky_lean", "file": "Cocky_Lean_Back__c9c90e3c.fbx", "mixamo": "Being Cocky", "loop": false},
{"id": "idle_yawn", "file": "Big_Yawn_While_Standing__c9c98361.fbx", "mixamo": "Yawn", "loop": false},
{"id": "idle_happy_1", "file": "Happy_Idle_Variation_1__c9ccf37e.fbx", "mixamo": "Happy Idle", "loop": true},
{"id": "idle_happy_2", "file": "Happy_Idle_Variation_2__c9ccf445.fbx", "mixamo": "Happy Idle", "loop": true},
{"id": "idle_old_man", "file": "Old_Man_Standing_Idle__c9c6c004.fbx", "mixamo": "Old Man Idle", "loop": true},
{"id": "idle_smoking", "file": "Idle_Smoking__c9c603fd.fbx", "mixamo": "Smoking", "loop": true}
]
},
"browse": {
"category": "browse",
"desc": "shop-floor: hold / examine / open / carry / pick up",
"clips": [
{"id": "browse_hold_idle", "file": "Holding_An_Object_Idle__c9cda11a.fbx", "mixamo": "Holding Idle", "loop": true},
{"id": "browse_hold_walk", "file": "Holding_An_Object_While_Walking__c9cda1e7.fbx", "mixamo": "Holding Walk", "loop": true},
{"id": "browse_hold_turn_l", "file": "Holding_An_Object_While_Turning_Left__c9cda2af.fbx", "mixamo": "Holding Turn Left", "loop": false, "pair": "browse_hold_turn_r"},
{"id": "browse_hold_turn_r", "file": "Holding_An_Object_While_Turning_Right__c9cda378.fbx", "mixamo": "Holding Turn Right", "loop": false, "pair": "browse_hold_turn_l"},
{"id": "browse_open_cabinet", "file": "Opening_Cabinet_Door__c9c6d49e.fbx", "mixamo": "Opening", "loop": false, "pair": "browse_close_cabinet"},
{"id": "browse_close_cabinet","file": "Closing_Cabinet_Door__c9c6d55d.fbx", "mixamo": "Closing", "loop": false, "pair": "browse_open_cabinet"},
{"id": "browse_carry_box", "file": "Carrying_A_Box__c9cd8d90.fbx", "mixamo": "Box Idle", "loop": true},
{"id": "browse_pick_up", "file": "Picking_Up_An_Object_With_One_Hand__c9c6c5eb.fbx","mixamo": "Picking Up Object","loop": false}
]
},
"sitlean": {
"category": "sitlean",
"desc": "benches and shopfronts: 4 seated + 4 leaning",
"clips": [
{"id": "sit_bench_swing", "file": "Little_Girl_Sitting_On_A_Bench_Swinging_Her_Legs__c9c89165.fbx", "mixamo": "Sitting", "loop": true},
{"id": "sit_hands_thighs", "file": "Sitting_In_Chair_Hands_Resting_On_Thighs__c9cb9783.fbx", "mixamo": "Sitting Idle", "loop": true},
{"id": "sit_impatient", "file": "Sitting_And_Impatiently_Waiting__c9ccad56.fbx", "mixamo": "Sitting", "loop": true},
{"id": "sit_fidget_feet", "file": "Sit_With_Fidgeting_Feet__c9c699a5.fbx", "mixamo": "Sitting", "loop": true},
{"id": "lean_wall_postures", "file": "Leaning_Against_A_Wall_With_Various_Postures__c9c695f0.fbx", "mixamo": "One Shoulder Lean", "loop": true},
{"id": "lean_wall_leg_up", "file": "Leaning_Square_Against_A_Wall_With_One_Leg_Up__c9c696b0.fbx", "mixamo": "Leaning", "loop": true},
{"id": "lean_wall_shoulders", "file": "Leaning_Square_With_Shoulders_Against_A_Wall__c9c69770.fbx", "mixamo": "Leaning", "loop": true},
{"id": "lean_wall_walkman", "file": "Little_Girl_Leaning_On_A_Wall_Listening_To_Music__c9c8bace.fbx", "mixamo": "Leaning On A Wall", "loop": true}
]
},
"social": {
"category": "social",
"desc": "two-person street/shop talk; the handshake pair stays paired",
"clips": [
{"id": "social_conversation", "file": "General_Conversation__c9c88301.fbx", "mixamo": "Talking", "loop": true},
{"id": "social_chat_water", "file": "Having_A_Chat_At_The_Watercooler__c9c9f1c9.fbx", "mixamo": "Talking At Watercooler","loop": true},
{"id": "social_greeting", "file": "Greeting_While_Standing__c9c7996a.fbx", "mixamo": "Standing Greeting", "loop": false},
{"id": "social_shake_1", "file": "2_People_Shaking_Hands_Part_1_-_Female__c9c9f343.fbx", "mixamo": "Shaking Hands 1", "loop": false, "pair": "social_shake_2"},
{"id": "social_shake_2", "file": "2_People_Shaking_Hands_Part_2_-_Male__c9c9f403.fbx", "mixamo": "Shaking Hands 2", "loop": false, "pair": "social_shake_1"},
{"id": "social_nod_yes", "file": "Nodding_Head_Yes__c9c90d7b.fbx", "mixamo": "Head Nod Yes", "loop": false},
{"id": "social_shake_no", "file": "Shaking_Head_No_Dismissively__c9c90b3c.fbx", "mixamo": "Shaking Head No", "loop": false},
{"id": "social_wave", "file": "Waving__c9c5ed32.fbx", "mixamo": "Waving", "loop": false}
]
},
"locomotion": {
"category": "locomotion",
"desc": "civilian walk + turn set (the named Degree_Turn set is rifle-aiming — substituted, see AUDIT)",
"clips": [
{"id": "walk_shopping_bag", "file": "Male_Walking_With_Shopping_Bag__c9c64027.fbx", "mixamo": "Walking With Shopping Bag", "loop": true},
{"id": "turn_left_90", "file": "Turning_90_Degrees_Left__c9ceef5f.fbx", "mixamo": "Unarmed Turn Left 90", "loop": false, "pair": "turn_right_90"},
{"id": "turn_right_90", "file": "Turning_90_Degrees_Right__c9cef01d.fbx", "mixamo": "Unarmed Turn Right 90", "loop": false, "pair": "turn_left_90"},
{"id": "turn_walk_180", "file": "Turning_180_Degrees_While_Walking__c9c9c73f.fbx", "mixamo": "Walking Turn 180", "loop": false},
{"id": "turn_in_place", "file": "Turning_In_Place__c9c70ef0.fbx", "mixamo": "Turning", "loop": true},
{"id": "walk_to_stand", "file": "Walking_To_Standing_Idle__c9c8d966.fbx", "mixamo": "Stop Walking", "loop": false}
]
},
"venue": {
"category": "venue",
"desc": "pub / gig / record shop",
"clips": [
{"id": "venue_clap_stand", "file": "Clap_While_Standing__c9c60ef2.fbx", "mixamo": "Clapping", "loop": true},
{"id": "venue_clap_seated", "file": "Clap_While_Seated__c9c60fce.fbx", "mixamo": "Clapping", "loop": true},
{"id": "venue_cheer", "file": "Male_Cheering_With_Two_Fists_Pump__c9c66dfd.fbx", "mixamo": "Cheering", "loop": true},
{"id": "venue_northern_soul", "file": "Basic_Northern_Soul_Step__c9cbe56d.fbx", "mixamo": "Northern Soul Dance","loop": true},
{"id": "venue_bartending", "file": "Pouring_And_Serving_Drinks__c9c78799.fbx", "mixamo": "Bartending", "loop": true},
{"id": "venue_headphones", "file": "Male_With_Headphones_Listening_To_Music__c9c63329.fbx", "mixamo": "Listening To Music", "loop": true}
]
}
}
}

155
pipeline/clips_verify.py Normal file
View File

@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""PROCITY R41 §41.1 — gate: motion_manifest.json <-> web/models/clips/*.glb integrity.
python3 pipeline/clips_verify.py # 0 errors = green
python3 pipeline/clips_verify.py --stage <dir> # + quantisation fidelity vs the float source
Checks, all measured from the bytes (Lane F: this is the "every referenced clip resolves" gate):
1. every group file named in the manifest exists, and its byte size matches the recorded one
2. every clip id resolves to a NAMED animation inside its group GLB, and nothing extra rides along
3. every group GLB is ZERO-DRAW 0 meshes, 0 materials, 0 images, no Draco
4. every group carries the same 66-node mixamorig skeleton (65 joints + Armature)
5. every animation channel targets a node that exists, on a path the manifest declares
6. recorded duration matches the max keyframe time in the file
7. `_rotOnly` survivability: every clip keeps >=1 non-Hips quaternion track, i.e. the clip still
says something after web/js/citizens/rigs.js filters it
8. with --stage: max angular error of the int16 rotation quantisation vs the float32 source
"""
import argparse
import json
import math
import os
import struct
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from clips_pack import acc_floats, canon_name, glb_read # noqa: E402
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--manifest", default="web/assets/motion_manifest.json")
ap.add_argument("--clips", default="web/models/clips")
ap.add_argument("--stage", help="staging dir of per-clip float32 GLBs (fidelity check)")
a = ap.parse_args()
man = json.load(open(a.manifest))
err, warn = [], []
total = 0
skel = None
for gfile, g in man["groups"].items():
p = os.path.join(a.clips, gfile)
if not os.path.exists(p):
err.append("%s: MISSING" % gfile)
continue
size = os.path.getsize(p)
total += size
if size != g["bytes"]:
err.append("%s: size %d != manifest %d" % (gfile, size, g["bytes"]))
js, bin_ = glb_read(p)
if js.get("meshes") or js.get("materials") or js.get("images"):
err.append("%s: NOT zero-draw (meshes=%d materials=%d images=%d)"
% (gfile, len(js.get("meshes", [])), len(js.get("materials", [])),
len(js.get("images", []))))
if any("draco" in e.lower() for e in js.get("extensionsUsed", [])):
err.append("%s: Draco present" % gfile)
names = [canon_name(n.get("name")) for n in js["nodes"]]
if skel is None:
skel = names
elif names != skel:
err.append("%s: skeleton differs from %s" % (gfile, list(man["groups"])[0]))
joints = [n for n in names if n.startswith("mixamorig:")]
if len(names) != 66 or len(joints) != 65:
err.append("%s: expected 66 nodes / 65 mixamorig joints, got %d / %d"
% (gfile, len(names), len(joints)))
anims = {an.get("name"): an for an in js.get("animations", [])}
if sorted(anims) != sorted(g["clips"]):
err.append("%s: animations %s != manifest clips %s"
% (gfile, sorted(anims), sorted(g["clips"])))
for cid in g["clips"]:
if cid not in anims:
continue
m = man["clips"][cid]
if m["group"] != gfile:
err.append("%s: clips[%s].group=%s" % (gfile, cid, m["group"]))
an = anims[cid]
dur, keeps = 0.0, 0
for ch in an["channels"]:
ni = ch["target"]["node"]
if not (0 <= ni < len(names)):
err.append("%s/%s: channel targets node %d out of range" % (gfile, cid, ni))
continue
if ch["target"]["path"] != "rotation" and man["_tracks"] == "rotation-only":
err.append("%s/%s: %s channel in a rotation-only build"
% (gfile, cid, ch["target"]["path"]))
if ch["target"]["path"] == "rotation" and not names[ni].endswith(":Hips"):
keeps += 1
t = acc_floats(js, bin_, an["samplers"][ch["sampler"]]["input"])
if t:
dur = max(dur, t[-1][0])
if abs(dur - m["duration"]) > 1e-3:
err.append("%s/%s: duration %.4f != manifest %.4f" % (gfile, cid, dur, m["duration"]))
if keeps == 0:
err.append("%s/%s: nothing survives rigs.js `_rotOnly`" % (gfile, cid))
if len(an["channels"]) != m["channels"]:
err.append("%s/%s: %d channels != manifest %d"
% (gfile, cid, len(an["channels"]), m["channels"]))
if total != man["totalBytes"]:
err.append("totalBytes %d != measured %d" % (man["totalBytes"], total))
if len(man["groups"]) > 6:
err.append("ruling 6: %d group files > 6" % len(man["groups"]))
if man["clipCount"] != len(man["clips"]):
err.append("clipCount mismatch")
# 8. quantisation fidelity against the float32 staging GLBs
if a.stage:
worst, worst_id = 0.0, None
for cid, m in man["clips"].items():
sp = os.path.join(a.stage, "%s.glb" % cid)
if not os.path.exists(sp):
warn.append("no staged source for %s" % cid)
continue
sj, sb = glb_read(sp)
gj_, gb = glb_read(os.path.join(a.clips, m["group"]))
snames = [canon_name(n.get("name")) for n in sj["nodes"]]
gnames = [canon_name(n.get("name")) for n in gj_["nodes"]]
ga = next(x for x in gj_["animations"] if x.get("name") == cid)
gmap = {gnames[c["target"]["node"]]: c for c in ga["channels"]
if c["target"]["path"] == "rotation"}
for ch in sj["animations"][0]["channels"]:
if ch["target"]["path"] != "rotation":
continue
bone = snames[ch["target"]["node"]]
if bone not in gmap:
continue
q0 = acc_floats(sj, sb, sj["animations"][0]["samplers"][ch["sampler"]]["output"])
q1 = acc_floats(gj_, gb, ga["samplers"][gmap[bone]["sampler"]]["output"])
if len(q0) != len(q1):
err.append("%s/%s: keyframe count %d != %d" % (cid, bone, len(q0), len(q1)))
continue
for x, y in zip(q0, q1):
if man["_encoding"].startswith("rotation outputs are int16"):
y = tuple(max(v / 32767.0, -1.0) for v in y)
d = abs(sum(p * q for p, q in zip(x, y)))
d /= (math.sqrt(sum(p * p for p in x)) * math.sqrt(sum(q * q for q in y)) or 1)
ang = math.degrees(2 * math.acos(max(-1.0, min(1.0, d))))
if ang > worst:
worst, worst_id = ang, "%s/%s" % (cid, bone)
print("quantisation: worst angular error %.6f deg (%s)" % (worst, worst_id))
if worst > 0.05:
err.append("quantisation error %.4f deg exceeds 0.05" % worst)
print("%d clips / %d groups / %d bytes (%.2f MB)"
% (man["clipCount"], len(man["groups"]), total, total / 1048576.0))
for w in warn:
print("WARN %s" % w)
for e in err:
print("ERROR %s" % e)
print("clips_verify: %s (%d errors, %d warnings)"
% ("GREEN" if not err else "RED", len(err), len(warn)))
return 1 if err else 0
if __name__ == "__main__":
sys.exit(main())

231
pipeline/contact_sheet.py Normal file
View File

@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""Render a labelled contact sheet of many GLBs in ONE Blender run — the eyeball tool.
The house rule is "always eyeball the thumbnails", and R41 §41.2 imports ~150 assets across three
libraries. One Blender startup per asset would cost more than the work; this lays every GLB out on
a grid in a single scene, one camera, one render, with a Blender text label and a metre reference
under each. Blender startup is the slow part, so 48 assets cost about what 2 do.
Every tile is framed to the SAME world scale by default (`--absolute`), which is the only way a
contact sheet can answer "is this metre-correct" a per-tile auto-zoom makes a 0.2 m stool and a
2 m fridge look identical, which is exactly how a wrong scale ships. A 1 m grid square sits under
each asset as the ruler.
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/contact_sheet.py -- OUT.png GLB [GLB ...]
"$BL" --background --python pipeline/contact_sheet.py -- OUT.png --cols 8 --tile 320 DIR/*.glb
Options (after OUT.png, in any order among the file list):
--cols N tiles per row (default: ceil(sqrt(n)))
--tile N pixel size per tile (default 256)
--fit per-tile auto-zoom instead of one shared scale (looks prettier, lies about size)
--title TEXT caption drawn at the top of the sheet
"""
import bpy, sys, os, math
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
OUT = ARGV[0]
rest = ARGV[1:]
COLS = TILE = None
FIT = False
TITLE = ""
files = []
i = 0
while i < len(rest):
a = rest[i]
if a == "--cols":
COLS = int(rest[i + 1]); i += 2
elif a == "--tile":
TILE = int(rest[i + 1]); i += 2
elif a == "--title":
TITLE = rest[i + 1]; i += 2
elif a == "--fit":
FIT = True; i += 1
else:
files.append(a); i += 1
TILE = TILE or 256
files = [f for f in files if f.lower().endswith(".glb")]
COLS = COLS or max(1, int(math.ceil(math.sqrt(len(files)))))
ROWS = int(math.ceil(len(files) / COLS))
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for c in list(bpy.data.collections):
bpy.data.collections.remove(c)
def bounds(objs):
lo = Vector((1e9,) * 3); hi = Vector((-1e9,) * 3)
for o in objs:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
for k in range(3):
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
if lo.x > 1e8:
return Vector((0, 0, 0)), Vector((0, 0, 0))
return lo, hi
def label(text, x, y, size, colour=(0.1, 0.1, 0.1, 1)):
cu = bpy.data.curves.new(type="FONT", name="lbl")
cu.body = text
cu.align_x = 'CENTER'
cu.size = size
ob = bpy.data.objects.new("lbl", cu)
ob.location = (x, y, 0)
m = bpy.data.materials.new("lblmat")
m.use_nodes = True
bsdf = m.node_tree.nodes.get("Principled BSDF")
bsdf.inputs["Base Color"].default_value = colour
bsdf.inputs["Roughness"].default_value = 1.0
ob.data.materials.append(m)
bpy.context.scene.collection.objects.link(ob)
return ob
def main():
wipe()
scn = bpy.context.scene
THETA = math.radians(58) # camera tilt from straight-down
PITCH = 1.0 # world metres between tile centres, ACROSS (x)
# Rows are separated along world Y, which the tilted camera foreshortens by cos(THETA) ~ 0.53,
# while object HEIGHT projects at sin(THETA) ~ 0.85. Using one pitch for both axes therefore
# stacks every tall asset on top of the row behind it. Space the rows out by the reciprocal.
PITCH_Y = PITCH / math.cos(THETA)
# --- import every GLB, park it on the grid ------------------------------------------------
placed = []
for n, path in enumerate(files):
r, c = divmod(n, COLS)
before = set(bpy.data.objects)
try:
bpy.ops.import_scene.gltf(filepath=path)
except Exception as e:
print(f"IMPORT FAIL {path}: {e}")
continue
new = [o for o in bpy.data.objects if o not in before]
roots = [o for o in new if o.parent is None]
meshes = [o for o in new if o.type == 'MESH']
if not meshes:
for o in new:
bpy.data.objects.remove(o, do_unlink=True)
print(f"NO MESH {path}")
continue
lo, hi = bounds(meshes)
size = max(hi.x - lo.x, hi.y - lo.y, hi.z - lo.z, 1e-4)
cx, cy = (lo.x + hi.x) / 2, (lo.y + hi.y) / 2
# scale: shared world scale (honest) or per-tile fit (pretty)
s = (PITCH * 0.62) / size if FIT else 1.0
tx, ty = c * PITCH, -r * PITCH_Y
for o in roots:
o.scale = (o.scale.x * s, o.scale.y * s, o.scale.z * s)
o.location = (o.location.x * s + tx - cx * s,
o.location.y * s + ty - cy * s,
o.location.z * s - lo.z * s)
placed.append((path, size, hi.z - lo.z, meshes))
label(os.path.basename(path)[:-4][:26], tx, ty - PITCH * 0.50, PITCH * 0.052)
label(f"{hi.x-lo.x:.2f} x {hi.y-lo.y:.2f} x {hi.z-lo.z:.2f} m",
tx, ty - PITCH * 0.60, PITCH * 0.042, (0.30, 0.30, 0.38, 1))
# --- a 1 m reference square under every tile (the ruler) -----------------------------------
if not FIT:
for n in range(len(placed)):
r, c = divmod(n, COLS)
bpy.ops.mesh.primitive_plane_add(size=1.0, location=(c * PITCH, -r * PITCH_Y, -0.002))
p = bpy.context.object
m = bpy.data.materials.new("grid")
m.use_nodes = True
b = m.node_tree.nodes.get("Principled BSDF")
b.inputs["Base Color"].default_value = (0.80, 0.80, 0.84, 1)
b.inputs["Roughness"].default_value = 1.0
p.data.materials.append(m)
# --- backdrop, light, camera ----------------------------------------------------------------
W = (COLS - 1) * PITCH
H = (ROWS - 1) * PITCH_Y
tall = max((t[2] for t in placed), default=1.0)
bpy.ops.mesh.primitive_plane_add(size=max(W, H) * 4 + 40, location=(W / 2, -H / 2, -0.004))
bg = bpy.context.object
m = bpy.data.materials.new("bg")
m.use_nodes = True
b = m.node_tree.nodes.get("Principled BSDF")
b.inputs["Base Color"].default_value = (0.94, 0.94, 0.96, 1)
b.inputs["Roughness"].default_value = 1.0
bg.data.materials.append(m)
scn.world.use_nodes = True
scn.world.node_tree.nodes["Background"].inputs[0].default_value = (0.9, 0.92, 0.96, 1)
scn.world.node_tree.nodes["Background"].inputs[1].default_value = 0.85
bpy.ops.object.light_add(type='SUN', location=(W / 2 + 3, -H / 2 - 4, 8 + tall))
sun = bpy.context.object
sun.data.energy = 1.9
sun.rotation_euler = (math.radians(52), 0, math.radians(38))
# A CONTACT SHEET IS A COLOUR JUDGEMENT, so it must not go through a film emulation.
# Blender's default view transform (AgX in 5.x) desaturates hard: the first pass of the
# art_incoming props read as bleached white and looked like the vertex-colour transfer had
# failed, when measuring COLOR_0 in the exported GLBs showed the means intact to within 3%.
# The tool was lying, not the asset. Standard = what you see is what is in the file.
scn.view_settings.view_transform = 'Standard'
scn.view_settings.look = 'None'
# Aim the camera AT the grid centre. A camera rotated theta about X looks along
# (0, sin theta, -cos theta), so it must sit back along the reverse of that ray from the target
# -- an arbitrary offset misses by t*sin(theta) and renders an empty backdrop (this tool's
# first take came back blank exactly that way).
tgt = Vector((W / 2, -H / 2 - PITCH * 0.15, PITCH * 0.20))
cam_d = (max(W, H) + tall) * 3.0 + 10.0
bpy.ops.object.camera_add(location=(tgt.x,
tgt.y - cam_d * math.sin(THETA),
tgt.z + cam_d * math.cos(THETA)))
cam = bpy.context.object
cam.data.type = 'ORTHO'
cam.data.clip_end = cam_d * 4
cam.rotation_euler = (THETA, 0, 0)
scn.camera = cam
# World extent the frame must contain. Screen-vertical mixes the ground span BETWEEN row
# centres ((ROWS-1) gaps, each PITCH_Y foreshortened by cos = PITCH) with the tallest object's
# height (x sin) and one label gutter. Counting a gutter PER ROW instead of between rows
# over-estimates badly at ROWS=1 and renders everything as postage stamps.
need_w = COLS * PITCH * 1.02
need_h = (ROWS - 1) * PITCH + tall * math.sin(THETA) + PITCH * 0.75 \
+ (PITCH * 0.42 if TITLE else 0.0)
res_x = int(COLS * TILE)
res_y = max(TILE // 2, int(res_x * need_h / need_w))
# ortho_scale governs the LARGER pixel dimension; solve so BOTH fit.
if res_x >= res_y:
cam.data.ortho_scale = max(need_w, need_h * res_x / res_y)
else:
cam.data.ortho_scale = max(need_h, need_w * res_y / res_x)
if TITLE:
# well clear of row 0: tall assets project upward in screen space from y=0, so a title
# parked at +0.6 m lands on top of them.
label(TITLE, W / 2, PITCH_Y * 0.72 + tall, PITCH * 0.085, (0.05, 0.05, 0.1, 1))
# Blender 5.x renamed the EEVEE enum back to BLENDER_EEVEE; keep both spellings working.
engines = scn.bl_rna.properties["render"].fixed_type.properties["engine"].enum_items.keys() \
if False else ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT')
for e in engines:
try:
scn.render.engine = e
break
except TypeError:
continue
scn.render.film_transparent = False
scn.render.resolution_x = res_x
scn.render.resolution_y = res_y
scn.render.image_settings.file_format = 'PNG'
scn.render.filepath = os.path.abspath(OUT)
os.makedirs(os.path.dirname(os.path.abspath(OUT)), exist_ok=True)
bpy.ops.render.render(write_still=True)
print(f"SHEET {OUT} {len(placed)}/{len(files)} rendered {COLS}x{ROWS}")
main()

245
pipeline/merge_prims.py Normal file
View File

@ -0,0 +1,245 @@
#!/usr/bin/env python3
"""Collapse a multi-material GLB to ONE primitive per mesh by baking baseColorFactor -> COLOR_0.
WHY THIS EXISTS (R41 §41.2, the 3dstore import)
-----------------------------------------------
`~/Documents/3dstore/` was handed over as "45 of 46 pass the house law as-is". On tris, scale,
Draco and textures that is true. On the number that actually decides whether Lane C can place them
it is not: **14 of the files carry 16 materials on a single mesh**, one flat colour per record
sleeve (`Mat_Sleeve_<hex>`). glTF gives each material its own primitive, and three.js
`GLTFLoader` instantiates one `Mesh` per primitive so a 1,020-tri "Black Tub" costs **16 draw
calls**, not one. The interior draw margin is 162. Ten tubs would be the entire budget.
The materials are pure `baseColorFactor` no textures, no UVs, nothing but a flat colour and a
metallic/roughness pair. That is exactly what a vertex colour reproduces. So we merge every
primitive of a mesh into one, write each source primitive's `baseColorFactor` into a `COLOR_0`
attribute on its own vertices, and emit a single white material with the dominant primitive's
PBR values. glTF multiplies `COLOR_0` by `baseColorFactor`, and both live in LINEAR space, so a
white factor times the original linear colour is **numerically the original colour**. Byte-exact
look, 16 draws -> 1.
This is deliberately NOT `gltf-transform optimize` (ruling 5) and NOT a Blender re-export: the
geometry is already correct and every extra tool is another chance to move it. We rewrite the
container and touch nothing but the material binding.
python3 pipeline/merge_prims.py IN.glb OUT.glb
python3 pipeline/merge_prims.py --check OUT.glb # report prims/draws/materials
"""
import json, os, struct, sys
COMPONENT = {5120: ("b", 1), 5121: ("B", 1), 5122: ("h", 2),
5123: ("H", 2), 5125: ("I", 4), 5126: ("f", 4)}
NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16}
# ── container io ─────────────────────────────────────────────────────────────────────────────────
def read_glb(path):
d = open(path, "rb").read()
if d[:4] != b"glTF":
raise ValueError(f"{path}: not a GLB")
off, gltf, binc = 12, None, b""
while off + 8 <= len(d):
clen, ctype = struct.unpack("<II", d[off:off + 8])
chunk = d[off + 8: off + 8 + clen]
if ctype == 0x4E4F534A:
gltf = json.loads(chunk.decode("utf-8"))
elif ctype == 0x004E4942:
binc = chunk
off += 8 + clen
return gltf, binc
def write_glb(path, gltf, binc):
js = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
js += b" " * ((4 - len(js) % 4) % 4)
binc += b"\x00" * ((4 - len(binc) % 4) % 4)
total = 12 + 8 + len(js) + (8 + len(binc) if binc else 0)
with open(path, "wb") as f:
f.write(b"glTF" + struct.pack("<II", 2, total))
f.write(struct.pack("<II", len(js), 0x4E4F534A) + js)
if binc:
f.write(struct.pack("<II", len(binc), 0x004E4942) + binc)
def read_accessor(gltf, binc, idx):
"""Return a list of tuples (or scalars) for accessor `idx`, honouring byteStride."""
a = gltf["accessors"][idx]
fmt, size = COMPONENT[a["componentType"]]
n = NCOMP[a["type"]]
count = a["count"]
if "bufferView" not in a: # all-zero accessor (spec-legal)
return [(0,) * n for _ in range(count)] if n > 1 else [0] * count
bv = gltf["bufferViews"][a["bufferView"]]
base = bv.get("byteOffset", 0) + a.get("byteOffset", 0)
stride = bv.get("byteStride") or (size * n)
out = []
for i in range(count):
off = base + i * stride
v = struct.unpack_from("<" + fmt * n, binc, off)
out.append(v if n > 1 else v[0])
return out
def norm_component(v, ctype):
"""glTF normalized-integer -> float, per the spec's exact divisors."""
if ctype == 5121:
return v / 255.0
if ctype == 5123:
return v / 65535.0
if ctype == 5120:
return max(v / 127.0, -1.0)
if ctype == 5122:
return max(v / 32767.0, -1.0)
return float(v)
# ── the merge ────────────────────────────────────────────────────────────────────────────────────
def merge(gltf, binc):
"""Rewrite every multi-primitive mesh as one primitive with baked COLOR_0. Returns a report."""
mats = gltf.get("materials", [])
new_bin = bytearray()
new_views, new_accs = [], []
def add_accessor(data, fmt, size, ncomp, atype, ctype, minmax=False, target=None):
while len(new_bin) % 4:
new_bin.append(0)
off = len(new_bin)
for v in data:
new_bin.extend(struct.pack("<" + fmt * ncomp, *(v if ncomp > 1 else (v,))))
bv = {"buffer": 0, "byteOffset": off, "byteLength": len(new_bin) - off}
if target:
bv["target"] = target
new_views.append(bv)
acc = {"bufferView": len(new_views) - 1, "componentType": ctype,
"count": len(data), "type": atype}
if minmax and data:
cols = list(zip(*data)) if ncomp > 1 else [data]
acc["min"] = [min(c) for c in cols]
acc["max"] = [max(c) for c in cols]
new_accs.append(acc)
return len(new_accs) - 1
report = []
for mi, mesh in enumerate(gltf.get("meshes", [])):
prims = mesh.get("primitives", [])
if not prims:
continue
pos, nrm, col, idx = [], [], [], []
tri_by_prim = []
for p in prims:
attrs = p["attributes"]
P = read_accessor(gltf, binc, attrs["POSITION"])
N = (read_accessor(gltf, binc, attrs["NORMAL"])
if "NORMAL" in attrs else [(0.0, 1.0, 0.0)] * len(P))
# existing COLOR_0 multiplies the factor (spec); normalize ints first
if "COLOR_0" in attrs:
ca = gltf["accessors"][attrs["COLOR_0"]]
raw = read_accessor(gltf, binc, attrs["COLOR_0"])
ct, nc = ca["componentType"], NCOMP[ca["type"]]
existing = [tuple(norm_component(x, ct) for x in (v if nc > 1 else (v,)))
for v in raw]
existing = [(e + (1.0,)) if len(e) == 3 else e for e in existing]
else:
existing = None
m = mats[p["material"]] if p.get("material") is not None and mats else {}
bcf = (m.get("pbrMetallicRoughness", {}) or {}).get("baseColorFactor", [1, 1, 1, 1])
bcf = tuple(float(x) for x in (list(bcf) + [1, 1, 1, 1])[:4])
base = len(pos)
pos.extend(P)
nrm.extend(N)
if existing:
col.extend(tuple(bcf[k] * existing[i][k] for k in range(4))
for i in range(len(P)))
else:
col.extend([bcf] * len(P))
if "indices" in p:
I = read_accessor(gltf, binc, p["indices"])
else:
I = list(range(len(P)))
idx.extend(i + base for i in I)
tri_by_prim.append((len(I) // 3, p.get("material")))
# PBR donor = the material covering the most triangles, so the dominant surface keeps its
# exact metallic/roughness. (On the 3dstore tubs that is a sleeve: metallic 0, rough 0.6.)
donor_mat = max(tri_by_prim, key=lambda t: t[0])[1] if tri_by_prim else None
donor = mats[donor_mat] if donor_mat is not None and mats else {}
dp = donor.get("pbrMetallicRoughness", {}) or {}
ctype = 5125 if len(pos) > 65535 else 5123
fmt = "I" if ctype == 5125 else "H"
i_acc = add_accessor(idx, fmt, 4 if ctype == 5125 else 2, 1, "SCALAR", ctype,
target=34963)
p_acc = add_accessor(pos, "f", 4, 3, "VEC3", 5126, minmax=True, target=34962)
n_acc = add_accessor(nrm, "f", 4, 3, "VEC3", 5126, target=34962)
c_acc = add_accessor(col, "f", 4, 4, "VEC4", 5126, target=34962)
mesh["primitives"] = [{
"attributes": {"POSITION": p_acc, "NORMAL": n_acc, "COLOR_0": c_acc},
"indices": i_acc, "material": 0, "mode": 4,
}]
report.append({"mesh": mi, "name": mesh.get("name"), "prims_before": len(prims),
"prims_after": 1, "tris": len(idx) // 3, "verts": len(pos)})
gltf["materials"] = [{
"name": "Merged_VertexColor",
"doubleSided": any(m.get("doubleSided") for m in mats) if mats else False,
"pbrMetallicRoughness": {
"baseColorFactor": [1, 1, 1, 1], # identity: COLOR_0 carries it all
"metallicFactor": dp.get("metallicFactor", 1.0),
"roughnessFactor": dp.get("roughnessFactor", 1.0),
},
}]
gltf["bufferViews"] = new_views
gltf["accessors"] = new_accs
gltf["buffers"] = [{"byteLength": len(new_bin)}]
for k in ("images", "textures", "samplers"): # nothing references them now
gltf.pop(k, None)
gltf["extensionsUsed"] = [e for e in gltf.get("extensionsUsed", [])
if "draco" not in e.lower()] or None
if not gltf["extensionsUsed"]:
gltf.pop("extensionsUsed")
return report, bytes(new_bin)
def check(path):
gltf, _ = read_glb(path)
meshes = gltf.get("meshes", [])
nodes = gltf.get("nodes", [])
draws = sum(len(meshes[n["mesh"]].get("primitives", [])) for n in nodes if "mesh" in n)
prims = sum(len(m.get("primitives", [])) for m in meshes)
tris = 0
for m in meshes:
for p in m.get("primitives", []):
a = gltf["accessors"][p["indices"]] if "indices" in p else \
gltf["accessors"][p["attributes"]["POSITION"]]
tris += a["count"] // 3
return {"file": os.path.basename(path), "draws": draws, "prims": prims,
"materials": len(gltf.get("materials", [])), "tris": tris,
"images": len(gltf.get("images", []))}
def main():
args = sys.argv[1:]
if args and args[0] == "--check":
for f in args[1:]:
print(check(f))
return 0
if len(args) != 2:
print(__doc__)
return 2
src, dst = args
gltf, binc = read_glb(src)
before = check(src)
rep, nb = merge(gltf, binc)
os.makedirs(os.path.dirname(os.path.abspath(dst)), exist_ok=True)
write_glb(dst, gltf, nb)
after = check(dst)
print(f"{os.path.basename(src):42} draws {before['draws']:>3} -> {after['draws']:>2} "
f"| mats {before['materials']:>2} -> {after['materials']} "
f"| tris {before['tris']} -> {after['tris']}"
f"{' *** TRI DRIFT ***' if before['tris'] != after['tris'] else ''}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -141,6 +141,150 @@ def clean_mesh(obj):
return 0
def nonmanifold_ratio(obj):
"""Fraction of vertices on a non-manifold edge — the number that predicts whether COLLAPSE
decimation can work at all. Measured, not guessed: see remesh_shellsoup()."""
n = len(obj.data.vertices)
if not n:
return 0.0
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='DESELECT')
bpy.ops.mesh.select_non_manifold()
bpy.ops.object.mode_set(mode='OBJECT')
return sum(1 for v in obj.data.vertices if v.select) / n
def remesh_shellsoup(obj, budget, voxel=None, thick=0.0):
"""Voxel-remesh a shell-soup mesh to a manifold surface, decimate to budget, and carry the
source's vertex colours across.
[R41 §41.2] `art_incoming`'s 48 pub props are trimesh output at ~200k tris that COLLAPSE
decimation cannot touch: loungeChair is 94% non-manifold (376,076 of 399,542 verts) and a
ratio-0.01 decimate moved it 1,319,836 -> 1,274,119, i.e. 3%. topiary behaved the same at 93%
non-manifold; pokie, at 29%, decimated 198k -> 47k normally. Decimate cannot merge across
overlapping shells, so the fix is to stop trying: rebuild the topology with a voxel remesh
(OpenVDB, always manifold), then decimate THAT, then transfer the colour back.
This is the same "throw the topology away" move as bake_lowpoly.py, minus the Cycles bake
these props carry COLOR_0 and no textures, so a vertex-colour transfer keeps them at one
material and zero texture bytes instead of adding a 1024px atlas each.
"""
src = obj
bpy.ops.object.select_all(action='DESELECT')
src.select_set(True)
bpy.context.view_layer.objects.active = src
bpy.ops.object.duplicate()
lp = bpy.context.view_layer.objects.active # the copy becomes the low-poly
lp.name = "LP"
dims = lp.dimensions
v = voxel or max(max(dims) / 150.0, 1e-4)
# A voxel remesh is a signed-distance field: it needs a VOLUME. Feed it a thin open shell and
# it returns a lattice of holes — the keg (a thin-walled scan) came back as a wire cage at
# every voxel size tried, while solid props like the couch remeshed cleanly. Giving the shell
# a wall thicker than one voxel first is what makes the field well-defined.
if thick:
sm = lp.modifiers.new("solid", 'SOLIDIFY')
sm.thickness = thick if thick > 0 else v * 2.5
sm.offset = 0.0
sm.use_even_offset = False
bpy.ops.object.modifier_apply(modifier=sm.name)
rm = lp.modifiers.new("remesh", 'REMESH')
rm.mode = 'VOXEL'
rm.voxel_size = v
rm.adaptivity = 0.0
bpy.ops.object.modifier_apply(modifier=rm.name)
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.quads_convert_to_tris()
bpy.ops.object.mode_set(mode='OBJECT')
n = len(lp.data.polygons)
if n > budget:
dm = lp.modifiers.new("dec", 'DECIMATE')
dm.ratio = max(0.002, budget / n)
bpy.ops.object.modifier_apply(modifier=dm.name)
# Carry colour (and UVs, if the source is textured) across from the dense original by
# nearest-face interpolation. The destination layers must be CREATED first with
# `datalayout_transfer` — a Data Transfer modifier applied without it silently transfers
# nothing and the prop exports colourless, which is exactly how the first run of this came
# back "colour LOST" on all six.
transferred = False
src_cols = list(src.data.color_attributes)
src_uvs = list(src.data.uv_layers)
if src_cols or src_uvs:
try:
dt = lp.modifiers.new("xfer", 'DATA_TRANSFER')
dt.object = src
loops, verts = set(), set()
if src_cols:
dom = (src.data.color_attributes.active_color or src_cols[0]).domain
(loops if dom == 'CORNER' else verts).add(
'COLOR_CORNER' if dom == 'CORNER' else 'COLOR_VERTEX')
if src_uvs:
loops.add('UV')
if loops:
dt.use_loop_data = True
dt.data_types_loops = loops
dt.loop_mapping = 'POLYINTERP_NEAREST'
if verts:
dt.use_vert_data = True
dt.data_types_verts = verts
dt.vert_mapping = 'POLYINTERP_NEAREST'
bpy.ops.object.datalayout_transfer(modifier=dt.name) # create dest layers
bpy.ops.object.modifier_apply(modifier=dt.name)
transferred = bool(lp.data.color_attributes) if src_cols else bool(lp.data.uv_layers)
except Exception as e:
print(f" colour/UV transfer failed: {e}")
# the voxel remesh can leave degenerate faces; Blender warns "mesh is not valid" on export
lp.data.validate(verbose=False)
lp_mesh_name = lp.data.name
bpy.data.objects.remove(src, do_unlink=True)
bpy.ops.object.select_all(action='DESELECT')
lp.select_set(True)
bpy.context.view_layer.objects.active = lp
print(f" remesh voxel={v:.4f} -> {n} tris -> {len(lp.data.polygons)} "
f"(colour {'transferred' if transferred else 'LOST'}) [{lp_mesh_name}]")
return lp, transferred
def wire_vertex_colour(obj):
"""Route the active colour attribute into Base Color on every material.
The glTF exporter only writes COLOR_0 when a material actually reads the attribute
(`export_vertex_color='MATERIAL'`), and these sources arrive with NO material block at all
Blender invents a plain grey one on import. Without this the remeshed props would export
colourless and every thumbnail would be grey.
"""
if not obj.data.color_attributes:
return False
cname = obj.data.color_attributes.active_color.name \
if obj.data.color_attributes.active_color else obj.data.color_attributes[0].name
if not obj.data.materials:
m = bpy.data.materials.new("vcol")
m.use_nodes = True
obj.data.materials.append(m)
for m in obj.data.materials:
if not m or not m.use_nodes:
continue
nt = m.node_tree
bsdf = nt.nodes.get("Principled BSDF")
if not bsdf:
continue
n = nt.nodes.new("ShaderNodeVertexColor")
n.layer_name = cname
nt.links.new(n.outputs["Color"], bsdf.inputs["Base Color"])
if "Specular IOR Level" in bsdf.inputs:
bsdf.inputs["Specular IOR Level"].default_value = 0.2
bsdf.inputs["Roughness"].default_value = 0.72
return True
def resize_textures(max_px=1024):
for img in bpy.data.images:
if img.size[0] > max_px or img.size[1] > max_px:
@ -154,14 +298,37 @@ def normalize_one(spec):
src = os.path.join(ROOT, spec["src"]) if not os.path.isabs(spec["src"]) else spec["src"]
obj = import_join(src)
clipped = clean_mesh(obj) # welds, drops loose geo, TRIANGULATES
tri_before = len(obj.data.polygons) # true source triangle count (post-triangulate)
tri_before = len(obj.data.polygons)
# shell-soup path: rebuild topology before the decimate loop, which cannot touch it
nm = 0.0
remeshed = vcol = False
if spec.get("remesh"):
nm = nonmanifold_ratio(obj)
obj, vcol = remesh_shellsoup(obj, spec.get("tris", 5000), spec.get("voxel"),
spec.get("shell_thickness", 0.0))
remeshed = True # true source triangle count (post-triangulate)
# stand upright (if mis-authored), then face -Z
rx, ry, rz = UP_ROT.get(spec.get("up", "none"), (0, 0, 0))
obj.rotation_euler = (rx, ry, rz)
if spec.get("yaw"):
obj.rotation_euler[2] += math.radians(spec["yaw"])
bpy.ops.object.transform_apply(rotation=True)
if rx or ry or rz or spec.get("yaw"):
# Select+activate explicitly: transform_apply acts on the SELECTION, and after clean_mesh
# (or a remesh) the object we mean is not guaranteed to still be it. A silently-skipped
# yaw is invisible in the log and ships a prop facing the wrong way.
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
obj.rotation_mode = 'XYZ'
before = tuple(round(v, 4) for v in obj.dimensions)
obj.rotation_euler = (rx, ry, rz)
if spec.get("yaw"):
obj.rotation_euler[2] += math.radians(spec["yaw"])
bpy.ops.object.transform_apply(rotation=True)
after = tuple(round(v, 4) for v in obj.dimensions)
if before == after and (spec.get("yaw", 0) % 180) not in (0,):
print(f" WARNING: yaw/up requested but dimensions unchanged {before} — not applied")
else:
print(f" oriented {before} -> {after}")
# solidify: cap open bottoms → watertight, white sole (finish_glb.py:54)
if spec.get("solidify"):
@ -235,17 +402,22 @@ def normalize_one(spec):
mn, mx = bounds(obj)
resize_textures(1024)
if spec.get("remesh") or spec.get("vertex_colour"):
vcol = wire_vertex_colour(obj) or vcol
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
out = os.path.join(OUT_DIR, name)
bpy.ops.export_scene.gltf(
filepath=out, export_format='GLB', export_yup=True, export_apply=True,
export_image_format='WEBP', export_image_quality=85,
)
exp = dict(filepath=out, export_format='GLB', export_yup=True, export_apply=True,
export_image_format='WEBP', export_image_quality=85)
try: # Blender >=4.2 gates COLOR_0 behind this enum
bpy.ops.export_scene.gltf(**exp, export_vertex_color='ACTIVE')
except TypeError:
bpy.ops.export_scene.gltf(**exp)
thumb = render_thumb(name.replace(".glb", ".png"))
return {
"src": spec["src"], "file": name, "out": out, "thumb": thumb,
"tri_before": tri_before, "tri_after": tri_after,
"remeshed": remeshed, "nonmanifold": round(nm, 3), "vertex_colour": vcol,
"size_m": [round(mx.x - mn.x, 3), round(mx.z - mn.z, 3), round(mx.y - mn.y, 3)],
"footprint": [round(mx.x - mn.x, 2), round(mx.y - mn.y, 2)],
}

View File

@ -67,13 +67,29 @@ def main():
drift_check(record)
return
only = sys.argv[sys.argv.index("--only") + 1] if "--only" in sys.argv else ""
# [R41] --files LIST.txt : publish exactly these basenames (one per line). `--only` is a
# substring match, which cannot express "these 110 assets from three libraries and nothing
# else". It matters because `_normalized/` is shared: other lanes stage rigs and clips in the
# same directory, and a blanket run would push whatever they happen to have half-written.
explicit = None
if "--files" in sys.argv:
listfile = sys.argv[sys.argv.index("--files") + 1]
explicit = {ln.strip() for ln in open(listfile) if ln.strip()}
# Passwordless tailnet path (round-3 decision #4): GOD3_DEPOT overridden to an allowlisted
# tailnet endpoint (e.g. http://100.94.195.115:8788) trusts this box — no cookie needed.
tailnet = DEPOT != "https://digalot.fyi/3god"
glbs = sorted(g for g in glob.glob(NORM + "/*.glb")
if os.path.basename(g) not in SKIP and (not only or only in os.path.basename(g)))
if os.path.basename(g) not in SKIP
and (explicit is None or os.path.basename(g) in explicit)
and (not only or only in os.path.basename(g)))
if not glbs:
print(f"no GLBs in {NORM} matching {only!r} — run normalize.py first"); sys.exit(1)
if explicit is not None:
missing = explicit - {os.path.basename(g) for g in glbs}
if missing:
print(f"ERROR: {len(missing)} name(s) in --files are not staged in {NORM}: "
f"{sorted(missing)[:5]}")
sys.exit(2)
print(f"depot: {DEPOT} assets: {len(glbs)} {'(DRY RUN)' if DRY else ''}"
+ (" [tailnet passwordless]" if tailnet else ""))
if not DRY and not PW and not tailnet:

70
pipeline/r41_3dstore.json Normal file
View File

@ -0,0 +1,70 @@
{
"_comment": [
"R41 §41.2 — the 3dstore import, BY NAME (integrator ruling 3: no wildcard copies).",
"Source: ~/Documents/3dstore/ (46 GLB). Every file below was measured with glb_stat.py on",
"2026-08-07, not taken from the handover note. 40 accepted, 6 rejected (see `reject`).",
"These assets need NO Blender pass: they are already metres, +Y up, base-origin, no Draco,",
"no textures, <=5k tris. The ONLY change is pipeline/merge_prims.py collapsing the",
"per-record-sleeve materials into one vertex-coloured primitive, because 16 materials on one",
"mesh is 16 draw calls and the interior margin is 162.",
"kind: bin|rack|counter|trolley|prop|arch|wall_mount — tells Lane C what it is standing on."
],
"accept": [
{"src": "Black Tub.glb", "id": "tub_black", "kind": "bin"},
{"src": "Black Tub long.glb", "id": "tub_black_long", "kind": "bin"},
{"src": "White Tub.glb", "id": "tub_white", "kind": "bin"},
{"src": "White Tub long.glb", "id": "tub_white_long", "kind": "bin"},
{"src": "Blue Crate.glb", "id": "crate_blue", "kind": "bin"},
{"src": "Wooden Rack Bin.glb", "id": "rack_bin_wooden", "kind": "bin"},
{"src": "endoffunk1.glb", "id": "bin_endoffunk", "kind": "bin"},
{"src": "chiu-long-bin.glb", "id": "chiu_bin", "kind": "bin"},
{"src": "chiu-long-bin-low.glb", "id": "chiu_bin_low", "kind": "bin"},
{"src": "chiu-half-bin-sideways.glb", "id": "chiu_bin_half", "kind": "bin"},
{"src": "chiu-long.glb", "id": "chiu_rack_long", "kind": "rack"},
{"src": "chiu-short.glb", "id": "chiu_rack_short", "kind": "rack"},
{"src": "mos-japhop.glb", "id": "mos_stack", "kind": "rack"},
{"src": "mos-japhop-top.glb", "id": "mos_bin_top", "kind": "bin"},
{"src": "mos-japhop-upper-mid.glb", "id": "mos_bin_upper_mid", "kind": "bin"},
{"src": "mos-japhop-lower-mid.glb", "id": "mos_bin_lower_mid", "kind": "bin"},
{"src": "mos-japhop-bottom.glb", "id": "mos_bin_bottom", "kind": "bin"},
{"src": "big-4x4x4-01.glb", "id": "bin_big_4x4", "kind": "rack"},
{"src": "tubs-4x2-wide.glb", "id": "tubs_4x2_wide", "kind": "rack"},
{"src": "tubs-4x2-vertical.glb", "id": "tubs_4x2_vertical", "kind": "rack"},
{"src": "white-tub-3x3.glb", "id": "tubs_3x3", "kind": "rack"},
{"src": "white-tub-3x3-long.glb", "id": "tubs_3x3_long", "kind": "rack"},
{"src": "3-top-4-low-tubs-on-shelf-long.glb", "id": "tubs_shelf_long", "kind": "rack"},
{"src": "3-top-4-low-tubs-on-shelf-wide.glb", "id": "tubs_shelf_wide", "kind": "rack"},
{"src": "wooden-rack-type-long.glb", "id": "wooden_rack_long", "kind": "rack"},
{"src": "wooden-rack-type-wide.glb", "id": "wooden_rack_wide", "kind": "rack"},
{"src": "library trolley.glb", "id": "library_trolley", "kind": "trolley"},
{"src": "STATION 1.glb", "id": "counter_station", "kind": "counter"},
{"src": "STATION 2.glb", "id": "counter_station_side", "kind": "counter"},
{"src": "window-booth.glb", "id": "window_booth", "kind": "prop"},
{"src": "vinylsleeve_v5.glb", "id": "vinyl_sleeve", "kind": "prop",
"rebase": true,
"_why": "the only 3dstore file NOT base-origin: minY -0.160 (centred, not seated)"},
{"src": "AIR CON.glb", "id": "aircon_unit", "kind": "wall_mount"},
{"src": "long guy.glb", "id": "shelf_board_long", "kind": "wall_mount"},
{"src": "short guy.glb", "id": "shelf_board_short", "kind": "wall_mount"},
{"src": "wall big.glb", "id": "wall_shelves_big", "kind": "wall_mount"},
{"src": "wall long.glb", "id": "wall_shelves_long", "kind": "wall_mount"},
{"src": "DOOR-front.glb", "id": "door_shop", "kind": "arch"},
{"src": "GREY COLUMN-FRONT.glb", "id": "column_square", "kind": "arch"},
{"src": "WHITE COLUMN.glb", "id": "column_wide", "kind": "arch"},
{"src": "ROOF COLUMN.glb", "id": "beam_roof", "kind": "arch"}
],
"reject": [
{"src": "3dmrp.glb",
"why": "499,948 tris (100x the 5k prop budget) + a 4096px texture. Named in the tasking as the one reject; measured and confirmed."},
{"src": "DOOR.glb",
"why": "0.010 m thick — a 1 cm plane, 12 tris. The solid door is DOOR-front.glb (0.19 m), which is accepted as door_shop."},
{"src": "GREY COLUMN.glb",
"why": "0.010 m thick plane, 12 tris. The solid post is GREY COLUMN-FRONT.glb (0.22 x 0.22 m), accepted as column_square."},
{"src": "Shelf Level.glb",
"why": "4.50 x 0.05 x 0.005 m — a 5 mm trim rail from the booth_room build script, not a placeable fitting."},
{"src": "Shelf Level long.glb",
"why": "2.60 x 0.05 x 0.005 m — same 5 mm trim rail, short version."},
{"src": "floor crate.glb",
"why": "0.34 x 0.34 x 0.002 m — a 2 mm floor decal. Would z-fight Lane C's interior floor; a real crate is crate_blue."}
]
}

View File

@ -0,0 +1,250 @@
[
{
"src": "TURNTABLE-1200-RIGGED.glb",
"id": "deck_1200",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "SL1.glb",
"id": "sl_deck_1",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "SL2.glb",
"id": "sl_deck_2",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "SL3.glb",
"id": "sl_deck_3",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "SL4.glb",
"id": "sl_deck_4",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "TTM54i.glb",
"id": "mixer_ttm54i",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "PMC-05PRO.glb",
"id": "mixer_pmc05pro",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "PMC-06ProA.glb",
"id": "mixer_pmc06pro",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "PMC-07Pro.glb",
"id": "mixer_pmc07pro",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "PMC-08Pro.glb",
"id": "mixer_pmc08pro",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "PMC-10.glb",
"id": "mixer_pmc10",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "PMC-20.glb",
"id": "mixer_pmc20",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "SH-DJ1200.glb",
"id": "mixer_shdj1200",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "MX-2200.glb",
"id": "mixer_mx2200",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "PMX-9000.glb",
"id": "mixer_pmx9000",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "TR-808.glb",
"id": "drum_808",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "TR-1000.glb",
"id": "drum_tr1000",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "SP-1200.glb",
"id": "sampler_sp1200",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "MPC3000.glb",
"id": "sampler_mpc3000",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "S900.glb",
"id": "sampler_s900",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "HD25.glb",
"id": "headphones_hd25",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "M44-7.glb",
"id": "cart_m447",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "CONCORDE-PRO.glb",
"id": "cart_concorde",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "CONCORDE-CASE.glb",
"id": "cart_case",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "HEADSHELL-TECHNICS.glb",
"id": "headshell_technics",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "STANTON-500.glb",
"id": "cart_stanton",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "ZIP-100.glb",
"id": "zip_drive",
"category": "fit",
"mode": "static",
"height_m": 0,
"tris": 5000
},
{
"src": "TURNTABLE-1200-RIGGED.glb",
"id": "deck_1200_rigged",
"category": "fit",
"mode": "rigged",
"height_m": 0,
"tris": 5000
},
{
"src": "TTM54i.glb",
"id": "mixer_ttm54i_rigged",
"category": "fit",
"mode": "rigged",
"height_m": 0,
"tris": 5000
},
{
"src": "PMC-05PRO.glb",
"id": "mixer_pmc05pro_rigged",
"category": "fit",
"mode": "rigged",
"height_m": 0,
"tris": 5000
},
{
"src": "SH-DJ1200.glb",
"id": "mixer_shdj1200_rigged",
"category": "fit",
"mode": "rigged",
"height_m": 0,
"tris": 5000
}
]

423
pipeline/r41_pub_batch.json Normal file
View File

@ -0,0 +1,423 @@
[
{
"src": "ashUrn.glb",
"id": "ash_urn",
"category": "fit",
"height_m": 0.75,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "ashtray.glb",
"id": "ashtray_post",
"category": "fit",
"height_m": 0.95,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "bainMarie.glb",
"id": "bain_marie",
"category": "fit",
"height_m": 0.95,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "balustrade.glb",
"id": "balustrade",
"category": "fit",
"height_m": 1.05,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "banquette.glb",
"id": "banquette",
"category": "fit",
"height_m": 0.85,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "barrier.glb",
"id": "crowd_barrier",
"category": "fit",
"height_m": 1.1,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "beerUmbrella.glb",
"id": "beer_umbrella",
"category": "fit",
"height_m": 2.3,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "binRound.glb",
"id": "bin_round",
"category": "fit",
"height_m": 0.75,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "bistroTable.glb",
"id": "bistro_table",
"category": "fit",
"height_m": 0.92,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "brokenBasin.glb",
"id": "broken_basin",
"category": "fit",
"height_m": 0.6,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "cableSnake.glb",
"id": "cable_snake",
"category": "fit",
"height_m": 0.2,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "canStack.glb",
"id": "can_stack",
"category": "fit",
"height_m": 0.45,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "cdj.glb",
"id": "cdj_deck",
"category": "fit",
"height_m": 0.15,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "champBucket.glb",
"id": "champ_bucket",
"category": "fit",
"height_m": 0.35,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "cocktailBar.glb",
"id": "cocktail_bar",
"category": "fit",
"height_m": 2.0,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "distroBox.glb",
"id": "distro_box",
"category": "fit",
"height_m": 1.2,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "djPlinth.glb",
"id": "dj_plinth",
"category": "fit",
"height_m": 1.0,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "fireExt.glb",
"id": "fire_ext",
"category": "fit",
"height_m": 0.6,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "firepit.glb",
"id": "firepit",
"category": "fit",
"height_m": 0.5,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "flightCase.glb",
"id": "flight_case",
"category": "fit",
"height_m": 0.75,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "jukebox.glb",
"id": "jukebox",
"category": "fit",
"height_m": 1.52,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1,
"yaw": 90
},
{
"src": "keg.glb",
"id": "keg",
"category": "fit",
"height_m": 0.6,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "loungeChair.glb",
"id": "lounge_chair",
"category": "fit",
"height_m": 0.8,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "marbleSink.glb",
"id": "marble_sink",
"category": "fit",
"height_m": 0.85,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "oldCouch.glb",
"id": "old_couch",
"category": "fit",
"height_m": 0.85,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "pallet.glb",
"id": "pallet",
"category": "fit",
"height_m": 0.2,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "picnicTable.glb",
"id": "picnic_table",
"category": "fit",
"height_m": 0.75,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "pieWarmer.glb",
"id": "pie_warmer",
"category": "fit",
"height_m": 0.55,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "pillar.glb",
"id": "concrete_plinth",
"category": "fit",
"height_m": 1.2,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "planter.glb",
"id": "planter",
"category": "fit",
"height_m": 0.55,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "plywoodBar.glb",
"id": "plywood_bar",
"category": "fit",
"height_m": 1.05,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "pokie.glb",
"id": "pokie",
"category": "fit",
"height_m": 1.7,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "raffleDrum.glb",
"id": "raffle_drum",
"category": "fit",
"height_m": 0.9,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "ropePost.glb",
"id": "rope_post",
"category": "fit",
"height_m": 1.0,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "sandwichBoard.glb",
"id": "sandwich_board",
"category": "fit",
"height_m": 1.0,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "smokeMachine.glb",
"id": "smoke_machine",
"category": "fit",
"height_m": 0.38,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "stackF1.glb",
"id": "speaker_stack",
"category": "fit",
"height_m": 1.8,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "strobe.glb",
"id": "strobe_light",
"category": "fit",
"height_m": 0.4,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "subBass.glb",
"id": "sub_bass",
"category": "fit",
"height_m": 0.7,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "topiary.glb",
"id": "topiary",
"category": "fit",
"height_m": 1.2,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "trough.glb",
"id": "urinal_trough",
"category": "fit",
"height_m": 0.7,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
},
{
"src": "wheelieBin.glb",
"id": "wheelie_bin",
"category": "fit",
"height_m": 1.1,
"tris": 5000,
"remesh": true,
"voxel": 0.011111111111111112,
"shell_thickness": -1
}
]

View File

@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Stage the named 3dstore fittings into the repo (R41 §41.2, step 1).
No Blender. The source assets already satisfy the house GLB law measured end to end metres,
+Y up, base-origin, no Draco, no textures, <=5k tris so putting them through `normalize.py`
would re-join, re-triangulate and re-export geometry that is already correct, which is three
chances to move something for no gain. The two things they DO need are done here:
1. pipeline/merge_prims.py collapse the per-sleeve materials to one vertex-coloured
primitive. 16 materials on one mesh is 16 draw calls; the interior margin is 162.
2. base-origin repair for the one file that needs it (vinylsleeve_v5, minY -0.160).
Reads pipeline/r41_3dstore.json (the by-name allow-list, ruling 3). Writes
pipeline/_normalized/procity_fit_<id>_01.glb, mirrors to web/assets/models/, and emits
pipeline/_r41_3dstore_results.json with measured footprints/heights for build_manifest.py.
python3 pipeline/r41_stage_3dstore.py [--src ~/Documents/3dstore] [--dry-run]
"""
import json, os, shutil, sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
sys.path.insert(0, HERE)
import merge_prims as MP
import glb_stat as GS
SPEC = os.path.join(HERE, "r41_3dstore.json")
NORM = os.path.join(ROOT, "pipeline", "_normalized")
MODELS = os.path.join(ROOT, "web", "assets", "models")
RESULTS = os.path.join(HERE, "_r41_3dstore_results.json")
def world_bbox(path):
"""World-space min/max, node transforms respected (glb_stat's own walk)."""
g, _ = GS._read_glb(path)
acc, meshes, nodes = g.get("accessors", []), g.get("meshes", []), g.get("nodes", [])
mn = [float("inf")] * 3
mx = [float("-inf")] * 3
scenes = g.get("scenes") or [{"nodes": list(range(len(nodes)))}]
def walk(ni, M):
n = nodes[ni]
W = GS._mat_mul(M, GS._node_matrix(n))
if "mesh" in n:
for p in meshes[n["mesh"]].get("primitives", []):
pos = p.get("attributes", {}).get("POSITION")
if pos is None:
continue
a = acc[pos]
if "min" not in a:
continue
for cx in (a["min"][0], a["max"][0]):
for cy in (a["min"][1], a["max"][1]):
for cz in (a["min"][2], a["max"][2]):
w = GS._xform(W, [cx, cy, cz])
for k in range(3):
mn[k] = min(mn[k], w[k]); mx[k] = max(mx[k], w[k])
for c in n.get("children", []):
walk(c, W)
I = [[1 if i == j else 0 for j in range(4)] for i in range(4)]
for r in scenes[g.get("scene", 0)].get("nodes", []):
walk(r, I)
return mn, mx
def rebase(path):
"""Seat the asset on y=0 and centre it in X/Z by wrapping the scene roots in one node.
Wrapping rather than editing each root's TRS keeps every original transform intact and is
exactly reversible the asset is correct, only its pivot was authored at the centre.
"""
g, binc = MP.read_glb(path)
mn, mx = world_bbox(path)
dx = -(mn[0] + mx[0]) / 2.0
dy = -mn[1]
dz = -(mn[2] + mx[2]) / 2.0
if abs(dx) < 1e-6 and abs(dy) < 1e-6 and abs(dz) < 1e-6:
return False, (0, 0, 0)
scenes = g.setdefault("scenes", [{"nodes": list(range(len(g.get("nodes", []))))}])
si = g.get("scene", 0)
roots = scenes[si].get("nodes", [])
g.setdefault("nodes", []).append({"name": "procity_base_origin",
"translation": [dx, dy, dz], "children": list(roots)})
scenes[si]["nodes"] = [len(g["nodes"]) - 1]
MP.write_glb(path, g, binc)
return True, (round(dx, 4), round(dy, 4), round(dz, 4))
def main():
args = sys.argv[1:]
dry = "--dry-run" in args
src_dir = os.path.expanduser("~/Documents/3dstore")
if "--src" in args:
src_dir = os.path.expanduser(args[args.index("--src") + 1])
spec = json.load(open(SPEC))
os.makedirs(NORM, exist_ok=True)
os.makedirs(MODELS, exist_ok=True)
results, errors = [], []
print(f"{'file':34} {'tris':>6} {'draws':>11} {'w x d x h (m)':>22} kind")
print("-" * 92)
for e in spec["accept"]:
s = os.path.join(src_dir, e["src"])
if not os.path.isfile(s):
errors.append({"src": e["src"], "error": "source missing"})
print(f"MISSING {e['src']}")
continue
name = f"procity_fit_{e['id']}_01.glb"
out = os.path.join(NORM, name)
try:
before = MP.check(s)
if dry:
print(f"{name:34} {before['tris']:>6} {before['draws']:>4} -> ? (dry-run)")
continue
# merge() mutates the glTF dict in place and returns the rebuilt BIN chunk
gltf, binc = MP.read_glb(s)
_rep, newbin = MP.merge(gltf, binc)
MP.write_glb(out, gltf, newbin)
moved = (False, (0, 0, 0))
if e.get("rebase"):
moved = rebase(out)
mn, mx = world_bbox(out)
after = MP.check(out)
if after["tris"] != before["tris"]:
errors.append({"src": e["src"], "error":
f"tri drift {before['tris']} -> {after['tris']}"})
w, h, d = round(mx[0] - mn[0], 3), round(mx[1] - mn[1], 3), round(mx[2] - mn[2], 3)
if abs(mn[1]) > 0.01:
errors.append({"src": e["src"], "error": f"not base-origin: minY={mn[1]:.3f}"})
shutil.copy2(out, os.path.join(MODELS, name))
results.append({
"src": e["src"], "id": e["id"], "kind": e["kind"], "file": name,
"tris": after["tris"], "draws_before": before["draws"],
"draws_after": after["draws"], "materials_before": before["materials"],
# floor at 1 cm: a 3 mm record sleeve rounds to 0.00 at 2 dp, and the validator
# (rightly) calls a zero-width footprint insane. The number Lane C places on must
# stay a positive metre value.
"footprint": [max(round(w, 2), 0.01), max(round(d, 2), 0.01)],
"height": max(round(h, 3), 0.01),
"size_m": [w, d, h], "rebased": moved[0], "rebase_delta": moved[1],
"thumb": f"thumbs/{name[:-4]}.png",
})
print(f"{name:34} {after['tris']:>6} {before['draws']:>4} -> {after['draws']:<4} "
f"{w:>7.2f} x{d:>6.2f} x{h:>6.2f} {e['kind']}"
+ (" [rebased]" if moved[0] else ""))
except Exception as ex:
errors.append({"src": e["src"], "error": str(ex)})
print(f"ERR {e['src']}: {ex}")
if dry:
return 0
db = sum(r["draws_before"] for r in results)
da = sum(r["draws_after"] for r in results)
json.dump({"results": results, "errors": errors, "rejects": spec["reject"],
"draws_before_total": db, "draws_after_total": da},
open(RESULTS, "w"), indent=2)
print("-" * 92)
print(f"staged {len(results)}/{len(spec['accept'])} rejected {len(spec['reject'])} "
f"errors {len(errors)}")
print(f"DRAW COST across the set: {db} -> {da} ({db - da} draws removed, "
f"{100 * (db - da) / max(db, 1):.0f}%)")
for e in errors:
print(f" ERR {e}")
print(f"-> {RESULTS}")
return 1 if errors else 0
if __name__ == "__main__":
sys.exit(main())

263
pipeline/rig_join.py Normal file
View File

@ -0,0 +1,263 @@
"""PROCITY DJ-gear normalizer — collapse draws WITHOUT breaking the named-node contract.
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/rig_join.py -- BATCH.json OUT_DIR [THUMB_DIR]
WHY (R41 §41.2, and the tension nobody had measured)
----------------------------------------------------
Integrator ruling 5 forbids `gltf-transform optimize` on this library: it collapsed a 696-node
mixer to 25 nodes and deleted `Fader_CROSS`. True and the reason it was reached for is also
true. Measured on ultra's 60 exports, these models cost, in DRAW CALLS:
TR-1000 2145 · MPC-XL 1319 · MP2016 1201 · TR-808 1092 · PMC-08Pro 751 · TTM54i 659
The interior draw margin is **162**. One PMC-08Pro is 4.6x the entire budget for a room. So the
library as shipped is unplaceable, and the fix that makes it placeable is the one that is banned
because both "join everything" and "join nothing" are wrong.
The third option is to join SELECTIVELY. Everything that is not a control, and everything under a
control that is not the control itself, is scenery: it merges into one body mesh. Each node named
in the contract keeps its own object, its own name, and its own pivot, so it can still be driven:
Platter_SPIN · Arm_YAW · Fader_PITCH · Fader_CROSS · Btn_STARTSTOP
That is 1 body + at most 5 controls = **<=6 draws** for a fully drivable deck, instead of 751 for
one that nobody can afford to place. `mode:"static"` drops the controls into the body too (1 draw)
for the dressing copies, which is what most of a record shop actually needs.
The contract is VERIFIED, not assumed: every model is scanned for contract nodes before and after,
and `_rig_results.json` records both lists so a deletion can never pass silently.
BATCH.json: [ {"src":"TTM54i.glb", "id":"mixer_ttm54i", "category":"fit",
"mode":"rigged"|"static", "height_m":0.10, "tris":5000, "yaw":0} ]
"""
import bpy, sys, os, math, json
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
BATCH = ARGV[0]
OUT_DIR = ARGV[1] if len(ARGV) > 1 else "/tmp/procity_rig"
THUMB_DIR = ARGV[2] if len(ARGV) > 2 else os.path.join(OUT_DIR, "thumbs")
ROOT = os.environ.get("PROCITY_MODELS_ROOT", os.path.expanduser("~/Documents/3D=models/dj-gear/glb"))
os.makedirs(OUT_DIR, exist_ok=True)
os.makedirs(THUMB_DIR, exist_ok=True)
CONTRACT = ["Platter_SPIN", "Arm_YAW", "Fader_PITCH", "Fader_CROSS", "Btn_STARTSTOP"]
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for blk in (bpy.data.meshes, bpy.data.materials, bpy.data.images):
for d in list(blk):
if d.users == 0:
blk.remove(d)
def world_bounds(objs):
lo = Vector((1e9,) * 3); hi = Vector((-1e9,) * 3)
for o in objs:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
for k in range(3):
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
return lo, hi
def descendants(o):
out = []
for c in o.children:
out.append(c)
out.extend(descendants(c))
return out
def join_into(active, others):
"""Join `others` into `active`, keeping active's origin (so a control keeps its pivot)."""
others = [o for o in others if o.type == 'MESH' and o is not active]
bpy.ops.object.select_all(action='DESELECT')
active.select_set(True)
for o in others:
o.select_set(True)
bpy.context.view_layer.objects.active = active
if others:
bpy.ops.object.join()
return active
def tris_of(o):
return len(o.data.polygons)
def decimate_to(o, budget):
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
bpy.context.view_layer.objects.active = o
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.quads_convert_to_tris()
bpy.ops.object.mode_set(mode='OBJECT')
n = tris_of(o)
passes = 0
while n > budget * 1.05 and passes < 6:
m = o.modifiers.new("dec", 'DECIMATE')
m.ratio = max(0.01, budget / n)
bpy.ops.object.modifier_apply(modifier=m.name)
new = tris_of(o)
passes += 1
if new >= n * 0.97:
n = new
break
n = new
return n
def scan_contract():
names = {o.name for o in bpy.data.objects}
return [c for c in CONTRACT if c in names]
def process(spec):
wipe()
src = spec["src"] if os.path.isabs(spec["src"]) else os.path.join(ROOT, spec["src"])
bpy.ops.import_scene.gltf(filepath=src)
before = scan_contract()
all_mesh = [o for o in bpy.data.objects if o.type == 'MESH']
tri_before = sum(tris_of(o) for o in all_mesh)
draws_before = len(all_mesh)
mode = spec.get("mode", "static")
budget = spec.get("tris", 5000)
keep, ctrl_meshes = [], []
if mode == "rigged":
# Each contract node absorbs its own subtree and keeps its pivot; scenery goes to the body.
#
# The named node stays whatever the source made it. In this library the controls are
# EMPTIES with the cap/stem/rib parented underneath (dj-gear/README.md: "moving the bare
# node moves the whole fader"), so the empty IS the contract and the pivot. An earlier
# pass promoted the biggest mesh child to carry the name and deleted the empty — which
# silently dropped the empty's translation and flung Btn_STARTSTOP to the origin, making
# the rigged deck 0.28 m tall against the static copy's 0.12 m. Keep the empty.
for name in before:
o = bpy.data.objects.get(name)
if not o:
continue
subtree = [d for d in descendants(o) if d.type == 'MESH']
if o.type == 'MESH':
join_into(o, subtree)
ctrl_meshes.append(o)
else:
if not subtree:
continue
host = max(subtree, key=tris_of)
join_into(host, [s for s in subtree if s is not host])
ctrl_meshes.append(host)
# detach from the source hierarchy, keeping the world placement
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
bpy.context.view_layer.objects.active = o
bpy.ops.object.parent_clear(type='CLEAR_KEEP_TRANSFORM')
keep.append(o)
keep_names = {o.name for o in keep} | {o.name for o in ctrl_meshes}
body_parts = [o for o in bpy.data.objects
if o.type == 'MESH' and o.name not in keep_names]
if not body_parts:
raise ValueError("no body geometry left")
body = max(body_parts, key=tris_of)
body = join_into(body, body_parts)
body.name = "Body"
# controls become children of the body so one transform moves the whole unit
for o in keep:
bpy.ops.object.select_all(action='DESELECT')
o.select_set(True)
body.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.object.parent_set(type='OBJECT', keep_transform=True)
ctrl_tris = sum(tris_of(o) for o in ctrl_meshes)
decimate_to(body, max(600, budget - ctrl_tris))
for o in ctrl_meshes: # keep controls readable, but bounded
if tris_of(o) > 400:
decimate_to(o, 400)
# orientation, height-normalise, base-origin — on the whole unit
objs = [body] + keep + ctrl_meshes
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = body
if spec.get("yaw"):
body.rotation_mode = 'XYZ'
body.rotation_euler[2] += math.radians(spec["yaw"])
bpy.context.view_layer.update()
lo, hi = world_bounds(objs)
h = spec.get("height_m", 0)
if h and h > 0:
s = h / max(hi.z - lo.z, 1e-6)
body.scale *= s
bpy.context.view_layer.update()
lo, hi = world_bounds(objs)
body.location -= Vector(((lo.x + hi.x) / 2, (lo.y + hi.y) / 2, lo.z))
bpy.context.view_layer.update()
lo, hi = world_bounds(objs)
for img in bpy.data.images:
if img.size[0] > 1024 or img.size[1] > 1024:
sc = 1024 / max(img.size)
img.scale(max(1, int(img.size[0] * sc)), max(1, int(img.size[1] * sc)))
name = f"procity_{spec['category']}_{spec['id']}_01.glb"
out = os.path.join(OUT_DIR, name)
bpy.ops.object.select_all(action='DESELECT')
for o in objs:
o.select_set(True)
bpy.context.view_layer.objects.active = body
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_yup=True,
use_selection=True, export_apply=False,
export_image_format='WEBP', export_image_quality=85)
after = scan_contract()
return {
"src": spec["src"], "id": spec["id"], "file": name, "mode": mode,
"tri_before": tri_before, "tri_after": sum(tris_of(o) for o in objs if o.type == 'MESH'),
"draws_before": draws_before,
"draws_after": sum(1 for o in objs if o.type == 'MESH'),
"contract_before": before, "contract_after": after,
"contract_ok": set(before) == set(after) if mode == "rigged" else True,
"controls": sorted(o.name for o in keep),
"size_m": [round(hi.x - lo.x, 3), round(hi.z - lo.z, 3), round(hi.y - lo.y, 3)],
"footprint": [max(round(hi.x - lo.x, 2), 0.01), max(round(hi.y - lo.y, 2), 0.01)],
"height": max(round(hi.z - lo.z, 3), 0.01),
"out": out, "thumb": f"thumbs/{name[:-4]}.png",
}
def main():
specs = json.load(open(BATCH))
results, errors = [], []
for spec in specs:
try:
r = process(spec)
results.append(r)
flag = "" if r["contract_ok"] else " *** CONTRACT BROKEN ***"
print(f"OK {r['file']:44} {r['mode']:6} tris {r['tri_before']:>7}->{r['tri_after']:>5} "
f"draws {r['draws_before']:>5}->{r['draws_after']:<3} "
f"contract {','.join(r['contract_after']) or '-'}{flag}")
except Exception as e:
import traceback
traceback.print_exc()
errors.append({"id": spec.get("id"), "src": spec.get("src"), "error": str(e)})
print(f"ERR {spec.get('src')}: {e}")
json.dump({"results": results, "errors": errors},
open(os.path.join(OUT_DIR, "_rig_results.json"), "w"), indent=2)
broken = [r for r in results if not r["contract_ok"]]
print(f"\nRIG_DONE ok={len(results)} err={len(errors)} contract_broken={len(broken)} -> {OUT_DIR}")
main()

112
pipeline/thumbs.py Normal file
View File

@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Render a 256px manifest thumbnail for each GLB — many per Blender session.
Separate from `normalize.py`'s `render_thumb` on purpose: that one is part of the normalize pass
and shades with BLENDER_WORKBENCH `color_type='TEXTURE'`, which renders a **vertex-coloured,
material-less** mesh flat grey. Two of R41's three libraries are exactly that (the 3dstore
fittings after `merge_prims.py`, and the `art_incoming` props, which carry COLOR_0 and no material
block at all), so their thumbnails would have shipped colourless. This renders in EEVEE off the
real material graph, so what Lane C sees in the thumb is what the game draws.
BL=/Applications/Blender.app/Contents/MacOS/Blender
"$BL" --background --python pipeline/thumbs.py -- OUT_DIR GLB [GLB ...]
"""
import bpy, sys, os, math
from mathutils import Vector
ARGV = sys.argv[sys.argv.index("--") + 1:]
OUT_DIR = ARGV[0]
FILES = [f for f in ARGV[1:] if f.lower().endswith(".glb")]
RES = 256
os.makedirs(OUT_DIR, exist_ok=True)
def wipe():
for o in list(bpy.data.objects):
bpy.data.objects.remove(o, do_unlink=True)
for blk in (bpy.data.meshes, bpy.data.materials, bpy.data.images):
for d in list(blk):
if d.users == 0:
blk.remove(d)
def bounds(objs):
lo = Vector((1e9,) * 3); hi = Vector((-1e9,) * 3)
for o in objs:
if o.type != 'MESH':
continue
for c in o.bound_box:
w = o.matrix_world @ Vector(c)
for k in range(3):
lo[k] = min(lo[k], w[k]); hi[k] = max(hi[k], w[k])
return lo, hi
def setup_world():
w = bpy.data.worlds.get("tw") or bpy.data.worlds.new("tw")
bpy.context.scene.world = w
w.use_nodes = True
w.node_tree.nodes["Background"].inputs[0].default_value = (0.85, 0.87, 0.92, 1)
w.node_tree.nodes["Background"].inputs[1].default_value = 1.6
def main():
scn = bpy.context.scene
for e in ('BLENDER_EEVEE', 'BLENDER_EEVEE_NEXT'):
try:
scn.render.engine = e
break
except TypeError:
continue
scn.render.film_transparent = True
scn.render.resolution_x = scn.render.resolution_y = RES
scn.render.image_settings.file_format = 'PNG'
scn.render.image_settings.color_mode = 'RGBA'
scn.view_settings.view_transform = 'Standard'
ok = 0
for path in FILES:
wipe()
setup_world()
try:
bpy.ops.import_scene.gltf(filepath=path)
except Exception as ex:
print(f"IMPORT FAIL {path}: {ex}")
continue
meshes = [o for o in bpy.data.objects if o.type == 'MESH']
if not meshes:
print(f"NO MESH {path}")
continue
lo, hi = bounds(meshes)
ctr = (lo + hi) / 2
size = max((hi - lo).x, (hi - lo).y, (hi - lo).z) or 1.0
bpy.ops.object.light_add(type='AREA',
location=(ctr.x + size, ctr.y - size, hi.z + size * 1.2))
L = bpy.context.object
L.data.energy = 260 * size * size + 40
L.data.size = size * 2.5
L.rotation_euler = (math.radians(40), 0, math.radians(35))
bpy.ops.object.light_add(type='AREA',
location=(ctr.x - size, ctr.y - size * 1.4, ctr.z))
L2 = bpy.context.object
L2.data.energy = 90 * size * size + 15
L2.data.size = size * 3
bpy.ops.object.camera_add()
cam = bpy.context.object
scn.camera = cam
cam.data.type = 'ORTHO'
cam.data.ortho_scale = size * 1.5
d = Vector((0.82, -1.0, 0.62)).normalized()
cam.location = ctr + d * (size * 4 + 2)
cam.rotation_euler = (-d).to_track_quat('-Z', 'Y').to_euler()
cam.data.clip_end = size * 20 + 50
scn.render.filepath = os.path.join(OUT_DIR, os.path.basename(path)[:-4] + ".png")
bpy.ops.render.render(write_still=True)
ok += 1
print(f"THUMBS_DONE {ok}/{len(FILES)} -> {OUT_DIR}")
main()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,631 @@
{
"_schema": "PROCITY motion manifest v1 (R41 41.1). groups{<file>} -> one GLB under web/models/clips/, holding N named animations. clips{<clipId>} -> {group,category,duration,loopable,source,mixamo,pair?}. Load a group ONCE (gltf.animations[] are named by clipId); index by clips[id].group.",
"_licence": "Mixamo (Adobe) via ultra ~/Documents/MOTIONLIB/green/mixamo_full/. GREEN: free to use inside games/projects, NO attribution required. Never redistribute as a clip pack. R41 ships this licence regime only (ruling 2).",
"_tracks": "rotation-only",
"_encoding": "rotation outputs are int16 NORMALIZED (glTF-legal, three.js r175 de-normalises in GLTFLoader._getArrayFromAccessor); all 65 joints animated incl. fingers",
"_note": "Skeleton-only, 0 tris, 66 nodes (65 mixamorig joints + Armature) \u2014 the same byte-shape as web/models/peds/idle.glb. rigs.js `_canon` folds the mixamorigN: prefix, `_rotOnly` keeps quaternions and drops Hips.quaternion.",
"fps": 30,
"groups": {
"idles.glb": {
"category": "idle",
"desc": "the clone-army killer: 10 distinct standing idles",
"clips": [
"idle_breathing",
"idle_standing",
"idle_look_around",
"idle_weight_shift",
"idle_cocky_lean",
"idle_yawn",
"idle_happy_1",
"idle_happy_2",
"idle_old_man",
"idle_smoking"
],
"bytes": 1029496,
"nodes": 66,
"durationSum": 78.267
},
"browse.glb": {
"category": "browse",
"desc": "shop-floor: hold / examine / open / carry / pick up",
"clips": [
"browse_hold_idle",
"browse_hold_walk",
"browse_hold_turn_l",
"browse_hold_turn_r",
"browse_open_cabinet",
"browse_close_cabinet",
"browse_carry_box",
"browse_pick_up"
],
"bytes": 476056,
"nodes": 66,
"durationSum": 30.833
},
"sitlean.glb": {
"category": "sitlean",
"desc": "benches and shopfronts: 4 seated + 4 leaning",
"clips": [
"sit_bench_swing",
"sit_hands_thighs",
"sit_impatient",
"sit_fidget_feet",
"lean_wall_postures",
"lean_wall_leg_up",
"lean_wall_shoulders",
"lean_wall_walkman"
],
"bytes": 358112,
"nodes": 66,
"durationSum": 27.533
},
"social.glb": {
"category": "social",
"desc": "two-person street/shop talk; the handshake pair stays paired",
"clips": [
"social_conversation",
"social_chat_water",
"social_greeting",
"social_shake_1",
"social_shake_2",
"social_nod_yes",
"social_shake_no",
"social_wave"
],
"bytes": 895648,
"nodes": 66,
"durationSum": 73.867
},
"locomotion.glb": {
"category": "locomotion",
"desc": "civilian walk + turn set (the named Degree_Turn set is rifle-aiming \u2014 substituted, see AUDIT)",
"clips": [
"walk_shopping_bag",
"turn_left_90",
"turn_right_90",
"turn_walk_180",
"turn_in_place",
"walk_to_stand"
],
"bytes": 212776,
"nodes": 66,
"durationSum": 12.133
},
"venue.glb": {
"category": "venue",
"desc": "pub / gig / record shop",
"clips": [
"venue_clap_stand",
"venue_clap_seated",
"venue_cheer",
"venue_northern_soul",
"venue_bartending",
"venue_headphones"
],
"bytes": 526036,
"nodes": 66,
"durationSum": 39.533
}
},
"clips": {
"idle_breathing": {
"group": "idles.glb",
"category": "idle",
"duration": 9.9667,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Breathing_Idle__c9c6d0d5.fbx",
"mixamo": "Breathing Idle",
"channels": 65,
"rotBones": 65
},
"idle_standing": {
"group": "idles.glb",
"category": "idle",
"duration": 6.0333,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Standing_Idle__c9c831cf.fbx",
"mixamo": "Standing Idle",
"channels": 65,
"rotBones": 65
},
"idle_look_around": {
"group": "idles.glb",
"category": "idle",
"duration": 6.3667,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Idle_Stand_Looking_Around__c9c6cdf7.fbx",
"mixamo": "Looking Around",
"channels": 65,
"rotBones": 65
},
"idle_weight_shift": {
"group": "idles.glb",
"category": "idle",
"duration": 9.4667,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Shifting_Weight_From_Side_To_Side__c9c908f7.fbx",
"mixamo": "Weight Shift",
"channels": 65,
"rotBones": 65
},
"idle_cocky_lean": {
"group": "idles.glb",
"category": "idle",
"duration": 2.9333,
"loopable": false,
"loopSeamDeg": 0.04,
"source": "Cocky_Lean_Back__c9c90e3c.fbx",
"mixamo": "Being Cocky",
"channels": 65,
"rotBones": 65
},
"idle_yawn": {
"group": "idles.glb",
"category": "idle",
"duration": 8.3667,
"loopable": false,
"loopSeamDeg": 0.04,
"source": "Big_Yawn_While_Standing__c9c98361.fbx",
"mixamo": "Yawn",
"channels": 65,
"rotBones": 65
},
"idle_happy_1": {
"group": "idles.glb",
"category": "idle",
"duration": 2.0333,
"loopable": true,
"loopSeamDeg": 0.28,
"source": "Happy_Idle_Variation_1__c9ccf37e.fbx",
"mixamo": "Happy Idle",
"channels": 65,
"rotBones": 65
},
"idle_happy_2": {
"group": "idles.glb",
"category": "idle",
"duration": 2.9667,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Happy_Idle_Variation_2__c9ccf445.fbx",
"mixamo": "Happy Idle",
"channels": 65,
"rotBones": 65
},
"idle_old_man": {
"group": "idles.glb",
"category": "idle",
"duration": 12.2,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Old_Man_Standing_Idle__c9c6c004.fbx",
"mixamo": "Old Man Idle",
"channels": 65,
"rotBones": 65
},
"idle_smoking": {
"group": "idles.glb",
"category": "idle",
"duration": 17.9333,
"loopable": true,
"loopSeamDeg": 0.37,
"source": "Idle_Smoking__c9c603fd.fbx",
"mixamo": "Smoking",
"channels": 65,
"rotBones": 65
},
"browse_hold_idle": {
"group": "browse.glb",
"category": "browse",
"duration": 6.0333,
"loopable": true,
"loopSeamDeg": 0.06,
"source": "Holding_An_Object_Idle__c9cda11a.fbx",
"mixamo": "Holding Idle",
"channels": 65,
"rotBones": 65
},
"browse_hold_walk": {
"group": "browse.glb",
"category": "browse",
"duration": 1.4,
"loopable": true,
"loopSeamDeg": 6.35,
"source": "Holding_An_Object_While_Walking__c9cda1e7.fbx",
"mixamo": "Holding Walk",
"channels": 65,
"rotBones": 65
},
"browse_hold_turn_l": {
"group": "browse.glb",
"category": "browse",
"duration": 1.2333,
"loopable": false,
"loopSeamDeg": 172.54,
"source": "Holding_An_Object_While_Turning_Left__c9cda2af.fbx",
"mixamo": "Holding Turn Left",
"channels": 65,
"rotBones": 65,
"pair": "browse_hold_turn_r"
},
"browse_hold_turn_r": {
"group": "browse.glb",
"category": "browse",
"duration": 1.3333,
"loopable": false,
"loopSeamDeg": 147.31,
"source": "Holding_An_Object_While_Turning_Right__c9cda378.fbx",
"mixamo": "Holding Turn Right",
"channels": 65,
"rotBones": 65,
"pair": "browse_hold_turn_l"
},
"browse_open_cabinet": {
"group": "browse.glb",
"category": "browse",
"duration": 4.8333,
"loopable": false,
"loopSeamDeg": 0.04,
"source": "Opening_Cabinet_Door__c9c6d49e.fbx",
"mixamo": "Opening",
"channels": 65,
"rotBones": 65,
"pair": "browse_close_cabinet"
},
"browse_close_cabinet": {
"group": "browse.glb",
"category": "browse",
"duration": 6.2,
"loopable": false,
"loopSeamDeg": 0.04,
"source": "Closing_Cabinet_Door__c9c6d55d.fbx",
"mixamo": "Closing",
"channels": 65,
"rotBones": 65,
"pair": "browse_open_cabinet"
},
"browse_carry_box": {
"group": "browse.glb",
"category": "browse",
"duration": 6.3333,
"loopable": true,
"loopSeamDeg": 0.03,
"source": "Carrying_A_Box__c9cd8d90.fbx",
"mixamo": "Box Idle",
"channels": 65,
"rotBones": 65
},
"browse_pick_up": {
"group": "browse.glb",
"category": "browse",
"duration": 3.4667,
"loopable": false,
"loopSeamDeg": 92.15,
"source": "Picking_Up_An_Object_With_One_Hand__c9c6c5eb.fbx",
"mixamo": "Picking Up Object",
"channels": 65,
"rotBones": 65
},
"sit_bench_swing": {
"group": "sitlean.glb",
"category": "sitlean",
"duration": 2.1333,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Little_Girl_Sitting_On_A_Bench_Swinging_Her_Legs__c9c89165.fbx",
"mixamo": "Sitting",
"channels": 65,
"rotBones": 65
},
"sit_hands_thighs": {
"group": "sitlean.glb",
"category": "sitlean",
"duration": 6.4333,
"loopable": true,
"loopSeamDeg": 0.05,
"source": "Sitting_In_Chair_Hands_Resting_On_Thighs__c9cb9783.fbx",
"mixamo": "Sitting Idle",
"channels": 65,
"rotBones": 65
},
"sit_impatient": {
"group": "sitlean.glb",
"category": "sitlean",
"duration": 7.0333,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Sitting_And_Impatiently_Waiting__c9ccad56.fbx",
"mixamo": "Sitting",
"channels": 65,
"rotBones": 65
},
"sit_fidget_feet": {
"group": "sitlean.glb",
"category": "sitlean",
"duration": 1.2,
"loopable": true,
"loopSeamDeg": 0.19,
"source": "Sit_With_Fidgeting_Feet__c9c699a5.fbx",
"mixamo": "Sitting",
"channels": 65,
"rotBones": 65
},
"lean_wall_postures": {
"group": "sitlean.glb",
"category": "sitlean",
"duration": 2.0333,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Leaning_Against_A_Wall_With_Various_Postures__c9c695f0.fbx",
"mixamo": "One Shoulder Lean",
"channels": 65,
"rotBones": 65
},
"lean_wall_leg_up": {
"group": "sitlean.glb",
"category": "sitlean",
"duration": 3.3667,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Leaning_Square_Against_A_Wall_With_One_Leg_Up__c9c696b0.fbx",
"mixamo": "Leaning",
"channels": 65,
"rotBones": 65
},
"lean_wall_shoulders": {
"group": "sitlean.glb",
"category": "sitlean",
"duration": 3.3667,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Leaning_Square_With_Shoulders_Against_A_Wall__c9c69770.fbx",
"mixamo": "Leaning",
"channels": 65,
"rotBones": 65
},
"lean_wall_walkman": {
"group": "sitlean.glb",
"category": "sitlean",
"duration": 1.9667,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Little_Girl_Leaning_On_A_Wall_Listening_To_Music__c9c8bace.fbx",
"mixamo": "Leaning On A Wall",
"channels": 65,
"rotBones": 65
},
"social_conversation": {
"group": "social.glb",
"category": "social",
"duration": 10.3,
"loopable": true,
"loopSeamDeg": 0.29,
"source": "General_Conversation__c9c88301.fbx",
"mixamo": "Talking",
"channels": 65,
"rotBones": 65
},
"social_chat_water": {
"group": "social.glb",
"category": "social",
"duration": 44.6,
"loopable": true,
"loopSeamDeg": 0.78,
"source": "Having_A_Chat_At_The_Watercooler__c9c9f1c9.fbx",
"mixamo": "Talking At Watercooler",
"channels": 65,
"rotBones": 65
},
"social_greeting": {
"group": "social.glb",
"category": "social",
"duration": 5.1333,
"loopable": false,
"loopSeamDeg": 0.04,
"source": "Greeting_While_Standing__c9c7996a.fbx",
"mixamo": "Standing Greeting",
"channels": 65,
"rotBones": 65
},
"social_shake_1": {
"group": "social.glb",
"category": "social",
"duration": 4.4,
"loopable": false,
"loopSeamDeg": 0.03,
"source": "2_People_Shaking_Hands_Part_1_-_Female__c9c9f343.fbx",
"mixamo": "Shaking Hands 1",
"channels": 65,
"rotBones": 65,
"pair": "social_shake_2"
},
"social_shake_2": {
"group": "social.glb",
"category": "social",
"duration": 4.4,
"loopable": false,
"loopSeamDeg": 0.13,
"source": "2_People_Shaking_Hands_Part_2_-_Male__c9c9f403.fbx",
"mixamo": "Shaking Hands 2",
"channels": 65,
"rotBones": 65,
"pair": "social_shake_1"
},
"social_nod_yes": {
"group": "social.glb",
"category": "social",
"duration": 2.6333,
"loopable": false,
"loopSeamDeg": 0.04,
"source": "Nodding_Head_Yes__c9c90d7b.fbx",
"mixamo": "Head Nod Yes",
"channels": 65,
"rotBones": 65
},
"social_shake_no": {
"group": "social.glb",
"category": "social",
"duration": 1.8333,
"loopable": false,
"loopSeamDeg": 0.04,
"source": "Shaking_Head_No_Dismissively__c9c90b3c.fbx",
"mixamo": "Shaking Head No",
"channels": 65,
"rotBones": 65
},
"social_wave": {
"group": "social.glb",
"category": "social",
"duration": 0.5667,
"loopable": false,
"loopSeamDeg": 0.04,
"source": "Waving__c9c5ed32.fbx",
"mixamo": "Waving",
"channels": 65,
"rotBones": 65
},
"walk_shopping_bag": {
"group": "locomotion.glb",
"category": "locomotion",
"duration": 1.3,
"loopable": true,
"loopSeamDeg": 0.03,
"source": "Male_Walking_With_Shopping_Bag__c9c64027.fbx",
"mixamo": "Walking With Shopping Bag",
"channels": 65,
"rotBones": 65
},
"turn_left_90": {
"group": "locomotion.glb",
"category": "locomotion",
"duration": 1.5333,
"loopable": false,
"loopSeamDeg": 84.6,
"source": "Turning_90_Degrees_Left__c9ceef5f.fbx",
"mixamo": "Unarmed Turn Left 90",
"channels": 65,
"rotBones": 65,
"pair": "turn_right_90"
},
"turn_right_90": {
"group": "locomotion.glb",
"category": "locomotion",
"duration": 1.5333,
"loopable": false,
"loopSeamDeg": 83.16,
"source": "Turning_90_Degrees_Right__c9cef01d.fbx",
"mixamo": "Unarmed Turn Right 90",
"channels": 65,
"rotBones": 65,
"pair": "turn_left_90"
},
"turn_walk_180": {
"group": "locomotion.glb",
"category": "locomotion",
"duration": 1.0333,
"loopable": false,
"loopSeamDeg": 180.0,
"source": "Turning_180_Degrees_While_Walking__c9c9c73f.fbx",
"mixamo": "Walking Turn 180",
"channels": 65,
"rotBones": 65
},
"turn_in_place": {
"group": "locomotion.glb",
"category": "locomotion",
"duration": 3.7,
"loopable": false,
"loopSeamDeg": 36.68,
"source": "Turning_In_Place__c9c70ef0.fbx",
"mixamo": "Turning",
"channels": 65,
"rotBones": 65,
"loopDemoted": "curated loopable but loop seam 36.7 deg > 25.0"
},
"walk_to_stand": {
"group": "locomotion.glb",
"category": "locomotion",
"duration": 3.0333,
"loopable": false,
"loopSeamDeg": 55.33,
"source": "Walking_To_Standing_Idle__c9c8d966.fbx",
"mixamo": "Stop Walking",
"channels": 65,
"rotBones": 65
},
"venue_clap_stand": {
"group": "venue.glb",
"category": "venue",
"duration": 1.2,
"loopable": true,
"loopSeamDeg": 0.07,
"source": "Clap_While_Standing__c9c60ef2.fbx",
"mixamo": "Clapping",
"channels": 65,
"rotBones": 65
},
"venue_clap_seated": {
"group": "venue.glb",
"category": "venue",
"duration": 1.2,
"loopable": true,
"loopSeamDeg": 0.07,
"source": "Clap_While_Seated__c9c60fce.fbx",
"mixamo": "Clapping",
"channels": 65,
"rotBones": 65
},
"venue_cheer": {
"group": "venue.glb",
"category": "venue",
"duration": 2.9333,
"loopable": true,
"loopSeamDeg": 0.04,
"source": "Male_Cheering_With_Two_Fists_Pump__c9c66dfd.fbx",
"mixamo": "Cheering",
"channels": 65,
"rotBones": 65
},
"venue_northern_soul": {
"group": "venue.glb",
"category": "venue",
"duration": 16.1,
"loopable": true,
"loopSeamDeg": 0.41,
"source": "Basic_Northern_Soul_Step__c9cbe56d.fbx",
"mixamo": "Northern Soul Dance",
"channels": 65,
"rotBones": 65
},
"venue_bartending": {
"group": "venue.glb",
"category": "venue",
"duration": 13.6,
"loopable": true,
"loopSeamDeg": 0.03,
"source": "Pouring_And_Serving_Drinks__c9c78799.fbx",
"mixamo": "Bartending",
"channels": 65,
"rotBones": 65
},
"venue_headphones": {
"group": "venue.glb",
"category": "venue",
"duration": 4.5,
"loopable": true,
"loopSeamDeg": 0.03,
"source": "Male_With_Headphones_Listening_To_Music__c9c63329.fbx",
"mixamo": "Listening To Music",
"channels": 65,
"rotBones": 65
}
},
"totalBytes": 3498124,
"clipCount": 46,
"groupCount": 6,
"dedupSavedBytes": 1341724
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Some files were not shown because too many files have changed in this diff Show More