Lane E R38 s2: the three GLBs PUBLISHED + sha1-verified off the public depot; the magpie is a magpie
A. DEPOT — closed, under John's R22 standing Lane-E authorization (the authority that
shipped sit.glb in R28 and the nine R36 sweep assets). Pushed via the passwordless
tailnet ingress: _published.json 53 -> 56, drift check clean. Then verified the way
this lane verifies — FETCHED BACK OFF THE PUBLIC DEPOT and hashed, not "the upload
said 200": magpie/paling_fence/water_tank all HTTP 200, byte counts exact, sha1
identical to local, 3/3. _r38_results.PENDING_PUBLISH.json renamed, build_manifest
re-run (GLBs 33 -> 36), validate_manifest 0 errors / 0 warnings.
B. THE MAGPIE TINT — done, not offered. The bird shipped as an all-black crow (R38 4d
traced the loss to trellis2_mlx's PBR bake). "Hand-tint the atlas" is not a paint job:
the bake is a smart-UV unwrap of ~100 islands and none of them announces itself as a
nape. So pipeline/tint_atlas.py defines the tint in the MESH's frame and projects it
through the UVs — rasterize every UV triangle to texel->(position,normal), evaluate
soft anatomical windows on the positions (white nape behind a black crown, shoulder
bar on the flanks only so the dorsal midline stays black, rump + tail base stopping
short of the black terminal band), paint keeping the bake's own luminance as shading,
then dilate 5 texels into the UV padding so no black fringe survives bilinear/mip.
3-D masks give one consistent soft edge across every island at once; a 2-D blur could
not (it would bleed island-to-island through the black background).
GEOMETRY UNTOUCHED byte for byte: still 894 tris / 1 mtl / 1 img / 512^2 WebP / no
Draco / 0.155x0.358x0.404 m. Painted luma median 0.84 (p90 0.95) vs the bake's 0.13.
VERIFIED IN THE GAME'S OWN RENDERER: the PUBLISHED file loaded off digalot.fyi through
the vendored GLTFLoader + three.js — 1 mesh, 894 tris, 512x512 texture decoded (which
also proves the PIL-written WebP decodes in a browser).
VERDICT: it reads. Pied and unmistakable at 64 and 48 px, still black-and-white at 32.
Honest caveat: from directly below-and-in-front it stays dark — correct, a real
magpie's bib and belly ARE black. Render: docs/shots/laneE/r38_magpie_tint.png.
Standing after B's 4514cb9: an UPGRADE PATH, not a dependency — B's 182-tri procedural
bird is 4.9x lighter and the right call. Nothing probes for the GLB and nothing should.
C. CORRECTIONS FOLDED INTO THE RECORD so the next asset round does not re-learn them:
skins are 9.7 s not 5.7 s (1024^2, 1.78x the pixels, per-pixel identical) - the reuse
list's "one line each" holds for 5 of 9 (extractGLB keeps only the FIRST material) -
normalize.py's collapse decimator floors at the shell count on TRELLIS output while
bake_lowpoly.py clears it every time - the 489 s outlier at 3.8x median means
throughput plans on a tail, not a mean - furniture.js:30's stale comment is fixed by B.
NEW, and the one worth keeping: an asset's tri budget is tris x THE INSTANCE COUNT THE
PLACEMENT RULE IMPLIES. paling_fence is a good 1,121-tri panel and a rejected asset —
905 instances = 1.01M tris, ~47x over. Ask the consuming lane for the instance count
BEFORE generating anything that tiles, edges or repeats per-lot.
New tools, all on-device and $0: tint_atlas.py, render_views.py (view sheet + --eevee +
roster-consistent --thumb), view_sheet.py (the distance strip no 256px thumbnail can make).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4514cb94b5
commit
74d6257940
@ -56,34 +56,59 @@ hoist is a floating horizontal smear. There is no voxel size that keeps a 30 mm
|
||||
at 600 tris. Both were **deleted from `_normalized/`** so no later manifest build can catalogue them.
|
||||
**Your `boxMats` primitives cost +0 draws; the GLBs would have cost a draw and looked worse.**
|
||||
|
||||
**Three shipped, staged but NOT yet on the depot** (see below) — measured, GLB-law clean,
|
||||
1 mtl / 1 img / 512² WebP / no Draco:
|
||||
**Three shipped — PUBLISHED and byte-verified off the public depot (session 2)** — measured,
|
||||
GLB-law clean, 1 mtl / 1 img / 512² WebP / no Draco:
|
||||
|
||||
| id | tris | footprint (m) | height (m) |
|
||||
|---|---|---|---|
|
||||
| `magpie` | **894** | 0.15 × 0.40 | 0.358 |
|
||||
| `paling_fence` | 1 121 | 0.28 × 3.59 | 1.788 |
|
||||
| `water_tank` | **500** | 1.73 × 1.64 | 2.186 |
|
||||
| id | tris | footprint (m) | height (m) | sha1 (local == depot) | B's verdict |
|
||||
|---|---|---|---|---|---|
|
||||
| `magpie` | **894** | 0.15 × 0.40 | 0.358 | `b5f02b26…ee10` (tinted) | superseded by B's 182-tri procedural bird — **upgrade path** |
|
||||
| `paling_fence` | 1 121 | 0.28 × 3.59 | 1.788 | `a580a883…8050` | **REJECTED on instance budget** — see the sizing law below |
|
||||
| `water_tank` | **500** | 1.73 × 1.64 | 2.186 | `9b08fb97…f360` | **ACCEPTED** — replaces a 24-tri pair at low count |
|
||||
|
||||
> **⚠ THE MAGPIE IS THE RIGHT SHAPE BUT THE WRONG COLOUR — read this before you place it.** At 894
|
||||
> tris the silhouette is genuinely good (beak, legs, folded wing, tail). But it is an **all-black
|
||||
> bird — a crow, not a magpie**: the white nape survives only as a pale grey wash and the white wing
|
||||
> bar and tail tip are gone. **Traced, not guessed**: the RMBG cutout fed to TRELLIS is a textbook
|
||||
> magpie, and the *raw 500 k* TRELLIS mesh has already lost it — so this is the operator's PBR bake,
|
||||
> not our decimation. `baker:"vertex"` (advertised as the clean-albedo path) returns a GLB with 0
|
||||
> materials / 0 images whose vertex colours `normalize.py` drops — it renders pure white. Fix path
|
||||
> for a later round: bake the albedo from the *concept image*, or hand-tint the 512² atlas. **If your
|
||||
> 1.3 needs the bird to read as a magpie at distance rather than as a dark silhouette, say so and I
|
||||
> will do the atlas tint as a micro-task.**
|
||||
### → FABLE / LANE B (session 2): THE MAGPIE NOW READS AS A MAGPIE
|
||||
|
||||
### → FABLE / JOHN: ONE outstanding step, deliberately not taken
|
||||
**The atlas tint is DONE, published and verified in the game's own renderer.** Render:
|
||||
`docs/shots/laneE/r38_magpie_tint.png` (before/after × 5 angles, a 64/48/32 px distance strip, and
|
||||
the published file loaded off `digalot.fyi/3god` through the vendored `GLTFLoader` + three.js).
|
||||
|
||||
The three GLBs are **not published to the 3GOD depot**, so they are **not in `manifest.json`** —
|
||||
pushing to a public depot is an outward-facing action and I had no in-session authorization for it
|
||||
this round. `validate_manifest.py` correctly fails any manifest GLB with no `_published.json` record,
|
||||
so the results file is held at **`pipeline/_r38_results.PENDING_PUBLISH.json`**. `build_manifest.py`
|
||||
is already wired. **Publish → rename to `_r38_results.json` → re-run `build_manifest.py`** and all
|
||||
three land. **As committed the gate is green: `validate_manifest.py` → 0 errors, 0 warnings.**
|
||||
**Method — `pipeline/tint_atlas.py`, and the reason it is not a paint job.** The baked atlas is a
|
||||
smart-UV unwrap: ~100 scattered islands, so you cannot tell by looking which island is a nape. So
|
||||
the tint is defined in the MESH's frame and projected through the UVs: rasterize every UV triangle
|
||||
→ texel→(position, normal) maps → evaluate soft anatomical windows on the POSITIONS (nape / wing
|
||||
bar / rump + tail base, with the crown, face, bib and tail tip left black) → paint, keeping the
|
||||
bake's own luminance as shading → dilate 5 texels into the bake's UV padding so no black fringe
|
||||
survives bilinear/mip at an island edge. **Geometry is untouched byte for byte** — only the image
|
||||
bufferView is rewritten: still 894 tris / 1 mtl / 1 img / 512² WebP / no Draco / 0.155 × 0.358 ×
|
||||
0.404 m, confirmed by `glb_stat.py` *and* by loading the published file in the vendored loader
|
||||
(1 mesh, 894 tris, `MeshStandardMaterial`, 512×512 texture decoded).
|
||||
Because the region masks are 3-D, the soft edge is consistent across every island at once — a 2-D
|
||||
blur could never do this (it would bleed island-to-island through the black background).
|
||||
|
||||
**VERDICT: it reads.** Painted albedo median luma **0.84** (p90 0.95) against the bake's 0.13.
|
||||
At **64 px and 48 px** — a 0.36 m bird at 3–10 m on a 1080-high screen — the pied pattern is
|
||||
unmistakable from flank and rear-quarter; at 32 px it is still clearly a black-and-white bird.
|
||||
**The one honest caveat:** from *directly underneath and in front* (the `swoop` view) it stays
|
||||
dark — which is what a real magpie does, because its bib and belly ARE black; the white is on the
|
||||
nape, shoulders and rump, i.e. what you see as it comes over you and as it leaves.
|
||||
**Thumbnail:** re-rendered from the **rear quarter** of the same `bake_lowpoly` camera rig, because
|
||||
the roster's standard front-quarter looks straight at a magpie's black face and records none of the
|
||||
plumage — the one thing this asset's thumb has to show.
|
||||
|
||||
**Status after B's R38 (commit `4514cb9`): this is an UPGRADE PATH, not a dependency.** B shipped a
|
||||
182-tri procedural bird carrying the marks as vertex colours and fetching nothing — 4.9× lighter
|
||||
and B's argument is right. The tinted GLB is on the depot and in the manifest if the swap at
|
||||
`birdGeometry()` / `birdMaterial()` is ever wanted; **nothing probes for it, and nothing should**
|
||||
(the blind probe R26 killed). It costs +1 draw and +894 tris at one instance if taken.
|
||||
|
||||
### → FABLE: the depot step is CLOSED
|
||||
|
||||
Published under **John's R22 standing authorization for Lane E assets** (the same authority that
|
||||
shipped `sit.glb` in R28 and the nine trellis/Rokoko assets in R36) via the passwordless tailnet
|
||||
ingress: `_published.json` **53 → 56**, drift check `✓ all 56 recorded assets live`. Then all three
|
||||
**fetched back off the PUBLIC depot** (`https://digalot.fyi/3god/a/<name>.glb`) — **HTTP 200, byte
|
||||
counts exact, sha1 identical to local, 3/3** (the R36 discipline; sha1s in the table above).
|
||||
`_r38_results.PENDING_PUBLISH.json` → `_r38_results.json`, `build_manifest.py` re-run (GLBs 33 → 36,
|
||||
they land in `furniture`), **`validate_manifest.py` → 0 errors, 0 warnings.**
|
||||
|
||||
### → FABLE: two review figures corrected, one confirmed
|
||||
|
||||
@ -104,7 +129,30 @@ three land. **As committed the gate is green: `validate_manifest.py` → 0 error
|
||||
`novelty_record`, `bench_modern`, `longbench` and `counter`→`bar_counter`.
|
||||
- **CONFIRMED — the stale blocker.** `furniture.js:30`'s comment holding `novelty_record` on its
|
||||
primitive says **"26.5k tris"**; Lane E baked it to **8 000** in R5. One stale comment is the only
|
||||
thing keeping a finished landmark prop out of the game.
|
||||
thing keeping a finished landmark prop out of the game. ✅ **Lane B corrected it in `4514cb9`.**
|
||||
- **CORRECTED — `normalize.py`'s collapse decimator cannot reach small budgets on TRELLIS output.**
|
||||
Measured on all five R38 props: 470 442→**13 849** against a 900 target · 478 236→**250 068** (600)
|
||||
· 476 974→**18 791** (400) · 485 565→**89 306** (400) · 475 047→**70 893** (500). Collapse cannot
|
||||
merge across disconnected shells, so **the floor IS the shell count** — this is not a tuning miss,
|
||||
and nobody had asked it for ≤900 before. **`bake_lowpoly.py` (voxel-remesh → decimate → Cycles
|
||||
bake) clears it every time**: 894 / 500 / 299 / 496. Route any ≤2 k target through the bake, never
|
||||
through collapse.
|
||||
|
||||
### → EVERY LATER ASSET ROUND: the sizing law R38 paid for (Lane B's `paling_fence` measurement)
|
||||
|
||||
**An asset's triangle budget is not "≤5 k like a prop" — it is `tris × the instance count the
|
||||
placement rule implies`.** `paling_fence` at **1 121 tris** is a perfectly good panel and a
|
||||
**REJECTED** asset: B measured **5 runs × 181 lots = 905 instances = 1.01 MILLION triangles**
|
||||
against ~75 k spare, ~47× over, and a GLB cannot ride the `boxMats` InstancedMesh that makes the
|
||||
other yard props free. It ships as a primitive. **`water_tank` at 500 tris was ACCEPTED on the same
|
||||
measurement** — one per yard at low count, replacing a 24-tri pair.
|
||||
**So the budget question at generation time is not "is this prop small?" but "how many of these does
|
||||
the placement rule put on screen, and can they ride an existing InstancedMesh?"** A fence panel
|
||||
repeated on every lot boundary is in a different budget class from a one-per-town water tank, and
|
||||
the number to beat is set by the *rule*, not by the asset. Ask Lane B for the instance count
|
||||
**before** generating anything that tiles, edges, or repeats per-lot. (The published fence stays on
|
||||
the depot and in the manifest — harmless, provenance-recorded, and available if a low-count use
|
||||
ever appears.)
|
||||
|
||||
## Round 36 wave 1 — the 2 a.m. commits, regularized ⟨v7.0⟩
|
||||
|
||||
|
||||
BIN
docs/shots/laneE/r38_magpie_tint.png
Normal file
BIN
docs/shots/laneE/r38_magpie_tint.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 347 KiB |
@ -732,3 +732,101 @@ ONE geometry and **keeps only the FIRST material**. So a multi-material GLB does
|
||||
**The 3GOD depot was NOT used as a pool** — it stays a read-only archive (971 live, 53 recorded,
|
||||
known trademarked items inside). **The hardyards set stays REJECTED on provenance** (39 GLBs,
|
||||
provenance documented for one file); every mesh here is ours. **$0 spent — no cloud key touched.**
|
||||
|
||||
### 7. SESSION 2 — the depot step, the magpie tint, and the sizing law
|
||||
|
||||
*Everything in §7 is measured on this box after `1568c9b`; nothing in §1–§6 is retracted.*
|
||||
|
||||
#### 7a. PUBLISHED — three GLBs, verified by fetching the bytes back off the PUBLIC depot
|
||||
|
||||
Pushed under **John's R22 standing authorization for Lane E assets** (the same authority as
|
||||
`sit.glb` in R28 and the nine R36 sweep assets) through the passwordless tailnet ingress
|
||||
(`GOD3_DEPOT=http://100.94.195.115:8788`, decision #4 — no secret read, no cookie, `publish.py`
|
||||
never logs one). `_published.json` **53 → 56**; post-publish drift check `✓ all 56 recorded assets
|
||||
live`. Thumbs uploaded with each (`glb:200 thumb:200`).
|
||||
|
||||
Then verified the way this lane always verifies: **fetch it back off the public host and compare
|
||||
hashes**, not "the upload said 200".
|
||||
|
||||
| file | HTTP | local B | depot B | sha1 (identical both sides) |
|
||||
|---|---|---|---|---|
|
||||
| `procity_street_magpie_01.glb` | 200 | 132 592 | 132 592 | `b5f02b26d15f7a58f8761f54fdb98b14fa65ee10` |
|
||||
| `procity_street_paling_fence_01.glb` | 200 | 165 752 | 165 752 | `a580a8833f02bccb2433776072bca2d4fbee8050` |
|
||||
| `procity_street_water_tank_01.glb` | 200 | 77 152 | 77 152 | `9b08fb9774e892f04d5c36a09ab2100383f4e360` |
|
||||
|
||||
`_r38_results.PENDING_PUBLISH.json` → `_r38_results.json`, `build_manifest.py` re-run — manifest
|
||||
GLBs **33 → 36**, all three under `furniture`. **`validate_manifest.py`: 0 errors, 0 warnings.**
|
||||
|
||||
#### 7b. THE MAGPIE TINT — a 3-D repaint of a 2-D atlas, because the atlas is unreadable by eye
|
||||
|
||||
§4d left the bird an all-black crow with the loss traced to `trellis2_mlx`'s PBR bake. The stated
|
||||
fix path ("hand-tint the 512² atlas") is **not a paint job**: the atlas is a smart-UV bake, ~100
|
||||
scattered islands, and no island announces itself as a nape. New tool **`pipeline/tint_atlas.py`**:
|
||||
|
||||
1. rasterize every UV triangle into the atlas grid → **texel → (position, normal)** maps
|
||||
(barycentric, glTF top-left UV origin, 1-texel conservative pad) — 101 086 / 262 144 texels
|
||||
covered, 38.6 %;
|
||||
2. evaluate **soft anatomical windows on the POSITIONS** in the mesh's own metric frame
|
||||
(x ±0.0777 lateral · y 0→0.3583 up · z −0.2021 tail → +0.2021 beak): white **nape** behind a
|
||||
black crown, white **shoulder/wing bar** on the flanks only so the dorsal midline stays black,
|
||||
white **rump + tail base** stopping short of the black terminal band — Australian magpie
|
||||
(*Gymnorhina tibicen*), adult, black-backed form;
|
||||
3. paint with the bake's own luminance kept as shading (`0.88 + 0.12·smoothstep(luma)`), so it is
|
||||
white with the bake's light in it, not a flat decal;
|
||||
4. **dilate 5 texels into the bake's UV padding**, or bilinear/mip at an island edge would pull the
|
||||
old black through;
|
||||
5. re-embed as WebP and rewrite the GLB. The image bufferView is verified last in the buffer, so
|
||||
**geometry is untouched byte for byte**.
|
||||
|
||||
Because the masks live in 3-D, the soft edge is consistent across every island simultaneously — a
|
||||
2-D blur cannot do this at all (it would bleed island-to-island through the black background).
|
||||
|
||||
**Result, measured:** painted texels' median luma **0.843** (p90 0.949) vs the bake's 0.133; whole
|
||||
atlas 0.075 → 0.207. **GLB law re-checked after the rewrite: 894 tris · 1 mesh · 1 material · 1
|
||||
image · 512² WebP · no Draco · 0.155 × 0.358 × 0.404 m — every number unchanged.**
|
||||
|
||||
**Verified in the game's own renderer, not just in Blender.** The *published* file was loaded off
|
||||
`https://digalot.fyi/3god` through the vendored `GLTFLoader` + three.js (`MeshStandardMaterial`,
|
||||
hemisphere + sun, sky background): **1 mesh, 894 tris, 512×512 texture decoded** — which also proves
|
||||
the PIL-written WebP decodes in a browser. Sheet: `docs/shots/laneE/r38_magpie_tint.png` —
|
||||
before/after × 5 angles (EEVEE, the GLB's own material incl. the 0.28 emissive) + a **64 / 48 / 32 px
|
||||
distance strip** (a 0.36 m bird at 3–10 m on a 1080-high screen is 50–100 px) + the three.js frame.
|
||||
|
||||
**VERDICT: it reads at the size and speed the bird is seen.** Pied and unmistakable at 64 px and
|
||||
48 px from flank and rear-quarter; still clearly black-and-white at 32 px. **Honest caveat:** from
|
||||
directly below-and-in-front it stays dark — correct behaviour, because a real magpie's bib and belly
|
||||
ARE black; the white is nape, shoulders and rump, i.e. what you see as it comes over and as it
|
||||
leaves. Thumbnail re-rendered from the **rear quarter** of the same `bake_lowpoly` camera rig
|
||||
(`render_views.py --thumb --rear`): the roster's standard front-quarter looks straight at a magpie's
|
||||
black face and would have recorded none of the plumage.
|
||||
|
||||
**Standing after Lane B's `4514cb9`: the tinted GLB is an UPGRADE PATH, not a dependency.** B shipped
|
||||
a 182-tri procedural bird with the marks as vertex colours, fetching nothing — 4.9× lighter, and the
|
||||
right call. The GLB is on the depot and in the manifest; **nothing probes for it and nothing should**
|
||||
(R26's blind-probe ruling). Cost if ever swapped in at `birdGeometry()` / `birdMaterial()`: +1 draw,
|
||||
+894 tris at one instance.
|
||||
|
||||
#### 7c. THE SIZING LAW — `paling_fence` is a good asset and a rejected one
|
||||
|
||||
Lane B's measurement: **5 runs × 181 lots = 905 instances × 1 121 tris = 1.01 M triangles** against
|
||||
~75 k spare (~47× over), and a GLB cannot ride the `boxMats` InstancedMesh that makes the other yard
|
||||
props +0 draws. **REJECTED; it ships as a primitive.** `water_tank` (500 tris, one per yard, low
|
||||
count, replacing a 24-tri pair) is **ACCEPTED**.
|
||||
|
||||
**The law for every later asset round: a mesh's triangle budget is `tris × the instance count the
|
||||
placement rule implies`, not "≤5 k like a prop".** A fence panel repeated on every lot boundary is
|
||||
in a different budget class from a one-per-town water tank, and the number to beat is set by the
|
||||
placement *rule*, not by the asset. **Get the instance count from the consuming lane BEFORE
|
||||
generating anything that tiles, edges, or repeats per-lot.** The published fence stays on the depot
|
||||
and in the manifest — harmless, provenance-recorded, available if a low-count use ever appears.
|
||||
|
||||
#### 7d. New pipeline tools (all $0, on-device)
|
||||
|
||||
| tool | what it does |
|
||||
|---|---|
|
||||
| `tint_atlas.py` | the 3-D-region atlas repaint above; `--debug` renders the region masks as flat colours so the anatomy can be checked before any paint lands |
|
||||
| `render_views.py` | Blender view sheet from the angles an asset is *seen* from (flank / rear-quarter / swoop / top / rear), `--eevee` for the GLB's own material under sky, `--thumb [--rear]` to re-render a roster-consistent 256 px thumbnail |
|
||||
| `view_sheet.py` | composites those into one labelled sheet + the **distance strip** — the check no 256 px thumbnail can make |
|
||||
|
||||
**Licence unchanged: 🟢.** The tint is our own numeric edit of our own baked atlas; no asset, no
|
||||
model and no third-party image entered the pipeline for it. **$0 — no cloud key touched.**
|
||||
|
||||
@ -44,9 +44,12 @@
|
||||
"procity_street_food_cart_01.glb",
|
||||
"procity_street_food_cart_01_hi.glb",
|
||||
"procity_street_longbench_01.glb",
|
||||
"procity_street_magpie_01.glb",
|
||||
"procity_street_novelty_record_01.glb",
|
||||
"procity_street_paling_fence_01.glb",
|
||||
"procity_street_park_bench_01.glb",
|
||||
"procity_street_streetlight_01.glb",
|
||||
"procity_street_water_tank_01.glb",
|
||||
"sit.glb",
|
||||
"vintage-cash-register.glb",
|
||||
"woman_dj_01.glb",
|
||||
|
||||
124
pipeline/render_views.py
Normal file
124
pipeline/render_views.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""PROCITY view sheet — render a normalized GLB from the angles it is actually SEEN from, plus a
|
||||
distance strip at the pixel sizes it actually occupies. Written for R38's magpie tint verdict:
|
||||
"does the marking read at the distance and speed the bird is seen at" is a question no 256 px
|
||||
thumbnail can answer.
|
||||
|
||||
BL=/Applications/Blender.app/Contents/MacOS/Blender
|
||||
"$BL" --background --python pipeline/render_views.py -- IN.glb OUT.png [LABEL]
|
||||
|
||||
Views (Blender frame after glTF import: glTF +Z head -> Blender -Y):
|
||||
flank · rear-quarter high (the nape read) · under-front (the swoop, bird above the player) ·
|
||||
top-down · straight rear. Each is composited over sky, not over transparency, because the
|
||||
contrast that matters is bird-against-sky. The bottom strip re-samples the flank + swoop view
|
||||
to 96 / 64 / 48 / 32 px — a 0.36 m bird at 3-10 m on a 1080-high screen is 50-100 px, so 64 px
|
||||
is the honest test and 32 px is the pessimistic one.
|
||||
"""
|
||||
import bpy, sys, os, math
|
||||
from mathutils import Vector
|
||||
|
||||
ARGV = sys.argv[sys.argv.index("--") + 1:]
|
||||
SRC, OUT = ARGV[0], ARGV[1]
|
||||
LABEL = ARGV[2] if len(ARGV) > 2 else ""
|
||||
RES = 384
|
||||
SKY = (0.62, 0.72, 0.86)
|
||||
|
||||
|
||||
def wipe():
|
||||
for o in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
|
||||
|
||||
def bounds(o):
|
||||
cs = [o.matrix_world @ Vector(c) for c in o.bound_box]
|
||||
return (Vector((min(c.x for c in cs), min(c.y for c in cs), min(c.z for c in cs))),
|
||||
Vector((max(c.x for c in cs), max(c.y for c in cs), max(c.z for c in cs))))
|
||||
|
||||
|
||||
def main():
|
||||
wipe()
|
||||
bpy.ops.import_scene.gltf(filepath=SRC)
|
||||
objs = [o for o in bpy.data.objects if o.type == 'MESH']
|
||||
ob = objs[0]
|
||||
mn, mx = bounds(ob)
|
||||
ctr = (mn + mx) / 2
|
||||
size = max((mx - mn).x, (mx - mn).y, (mx - mn).z) or 1.0
|
||||
|
||||
scn = bpy.context.scene
|
||||
if "--eevee" in ARGV:
|
||||
# closest cheap proxy for the in-game look: the GLB's OWN material (base colour + the
|
||||
# 0.28 emissive normalize.py adds) under a bright sky-coloured world + a sun.
|
||||
eng = [e.identifier for e in
|
||||
type(scn).bl_rna.properties['render'].fixed_type.properties['engine'].enum_items]
|
||||
scn.render.engine = 'BLENDER_EEVEE_NEXT' if 'BLENDER_EEVEE_NEXT' in eng else 'BLENDER_EEVEE'
|
||||
w = bpy.data.worlds.get("sky") or bpy.data.worlds.new("sky")
|
||||
scn.world = w
|
||||
w.use_nodes = True
|
||||
bg = w.node_tree.nodes["Background"]
|
||||
bg.inputs[0].default_value = (SKY[0], SKY[1], SKY[2], 1.0)
|
||||
bg.inputs[1].default_value = 1.6
|
||||
bpy.ops.object.light_add(type='SUN', location=(size * 3, -size * 3, size * 4))
|
||||
bpy.context.object.data.energy = 3.0
|
||||
bpy.context.object.rotation_euler = (math.radians(50), 0, math.radians(35))
|
||||
else:
|
||||
scn.render.engine = 'BLENDER_WORKBENCH'
|
||||
scn.display.shading.light = 'STUDIO'
|
||||
scn.display.shading.color_type = 'TEXTURE'
|
||||
scn.display.shading.show_cavity = False
|
||||
scn.view_settings.view_transform = 'Standard'
|
||||
scn.render.film_transparent = True
|
||||
scn.render.resolution_x = scn.render.resolution_y = RES
|
||||
bpy.ops.object.camera_add()
|
||||
cam = bpy.context.object
|
||||
scn.camera = cam
|
||||
cam.data.lens = 50
|
||||
|
||||
if "--thumb" in ARGV:
|
||||
# byte-for-byte the same camera/lighting as bake_lowpoly.render_thumb, so a re-tinted asset
|
||||
# gets a thumbnail that matches the rest of the roster instead of a new angle.
|
||||
scn.render.engine = 'BLENDER_WORKBENCH'
|
||||
scn.display.shading.light = 'STUDIO'
|
||||
scn.display.shading.color_type = 'TEXTURE'
|
||||
scn.display.shading.show_cavity = True
|
||||
w = bpy.data.worlds.get("tw") or bpy.data.worlds.new("tw")
|
||||
scn.world = w
|
||||
w.use_nodes = True
|
||||
w.node_tree.nodes["Background"].inputs[1].default_value = 1.05
|
||||
bpy.ops.object.light_add(type='AREA', location=(ctr.x + size, ctr.y - size, mx.z + size))
|
||||
bpy.context.object.data.energy = 800 * size
|
||||
bpy.context.object.data.size = 6
|
||||
r = size * 1.9
|
||||
# --rear swings the same rig to the other quarter. Needed for the magpie: the roster's
|
||||
# standard front-quarter looks straight at a magpie's black face and bib, so it records
|
||||
# none of the plumage — the one thing this asset's thumb has to show.
|
||||
cam.location = ctr + Vector((r * 0.8, r if "--rear" in ARGV else -r, size * 0.6))
|
||||
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
scn.render.resolution_x = scn.render.resolution_y = 256
|
||||
scn.render.filepath = OUT
|
||||
bpy.ops.render.render(write_still=True)
|
||||
print("RENDERED thumb -> " + OUT)
|
||||
return
|
||||
|
||||
r = size * 2.2
|
||||
# head is -Y after import; +Y is behind the bird
|
||||
views = {
|
||||
"flank": Vector((r, -r * 0.15, size * 0.15)),
|
||||
"rear34": Vector((r * 0.55, r * 0.85, size * 0.85)), # over the shoulder: the nape
|
||||
"swoop": Vector((r * 0.35, -r * 0.80, -size * 0.75)), # bird overhead, coming at you
|
||||
"top": Vector((0.001, 0.001, r * 1.15)),
|
||||
"rear": Vector((0.0, r * 1.1, size * 0.2)),
|
||||
}
|
||||
tmp = os.path.join(os.path.dirname(OUT), "_v_")
|
||||
paths = []
|
||||
for name, loc in views.items():
|
||||
cam.location = ctr + loc
|
||||
cam.rotation_euler = (ctr - cam.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
p = tmp + name + ".png"
|
||||
scn.render.filepath = p
|
||||
bpy.ops.render.render(write_still=True)
|
||||
paths.append((name, p))
|
||||
print("RENDERED " + " ".join(n for n, _ in paths))
|
||||
with open(OUT + ".views.txt", "w") as f:
|
||||
f.write("\n".join(f"{n}\t{p}" for n, p in paths) + f"\nLABEL\t{LABEL}\n")
|
||||
|
||||
|
||||
main()
|
||||
247
pipeline/tint_atlas.py
Normal file
247
pipeline/tint_atlas.py
Normal file
@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""PROCITY atlas tint — repaint anatomical REGIONS of a baked GLB atlas, in 3D, not in 2D.
|
||||
|
||||
Why this exists (R38): `trellis2_mlx`'s PBR bake crushes high-contrast markings. The magpie came
|
||||
back an all-black bird — a crow — with its white nape, wing bar and tail base gone (AUDIT R38 §4d
|
||||
proved the loss is the operator's bake: the RMBG cutout and the raw 500k mesh were both checked).
|
||||
The atlas is a smart-UV bake: ~100 scattered islands, so "hand-tint the white bits" is not a 2D
|
||||
paint job — you cannot tell which island is a nape.
|
||||
|
||||
So the tint is defined in the MESH's own space and projected through the UVs:
|
||||
|
||||
1. rasterize every UV triangle into the atlas grid -> texel -> (position, normal) maps
|
||||
2. evaluate soft anatomical region masks on those POSITIONS (metres, GLB frame: +Y up)
|
||||
3. paint the region colour with the bake's own luminance kept as shading
|
||||
4. dilate the result a few texels into the bake's UV padding so no black fringe survives
|
||||
bilinear/mip sampling at the island edges
|
||||
5. re-embed as WebP and rewrite the GLB — GEOMETRY IS UNTOUCHED, byte for byte. Only
|
||||
bufferView 'image' changes, so tris / materials / images / extensions all stay put.
|
||||
|
||||
PY=~/Documents/MODELBEAST/venvs/mflux/bin/python # numpy + PIL live here
|
||||
$PY pipeline/tint_atlas.py magpie --debug # flat region colours, for looking at
|
||||
$PY pipeline/tint_atlas.py magpie # the real tint
|
||||
$PY pipeline/tint_atlas.py magpie --dump-atlas /tmp/x.png # write the atlas alongside
|
||||
|
||||
Region masks are smooth (smoothstep ramps on windows of z / y / |x| / normal), so the paint has a
|
||||
soft edge in 3D and therefore across every island at once — which is exactly what a 2D blur could
|
||||
never do here (it would bleed island-to-island across the black background).
|
||||
"""
|
||||
import json, os, struct, sys, io
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- soft window helpers
|
||||
def smoothstep(e0, e1, x):
|
||||
t = np.clip((x - e0) / (e1 - e0 + 1e-12), 0.0, 1.0)
|
||||
return t * t * (3 - 2 * t)
|
||||
|
||||
|
||||
def window(v, lo_out, lo_in, hi_in, hi_out):
|
||||
"""1 inside [lo_in, hi_in], ramping to 0 at lo_out / hi_out."""
|
||||
return smoothstep(lo_out, lo_in, v) * (1.0 - smoothstep(hi_in, hi_out, v))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the region specs
|
||||
# The AUSTRALIAN MAGPIE (Gymnorhina tibicen), adult, black-backed form — the widespread depiction
|
||||
# and the one R38 asked for: WHITE NAPE (hindneck, behind a black crown), WHITE SHOULDER/WING BAR
|
||||
# along the top of the folded wing, WHITE RUMP running into a WHITE TAIL BASE with a black
|
||||
# terminal band. Beak pale blue-grey with a black tip (the bake already has that and it survives).
|
||||
#
|
||||
# Mesh frame, measured off the bytes: x = ±0.0777 lateral · y = 0 -> 0.3583 up ·
|
||||
# z = -0.2021 (tail tip, drooped to the ground) -> +0.2021 (beak tip). Head/crown z 0.10-0.135,
|
||||
# neck z 0.055-0.105, body z -0.06..+0.09, legs y<0.09, tail z<-0.06 sloping down to the ground.
|
||||
MAGPIE = {
|
||||
"file": "procity_street_magpie_01.glb",
|
||||
"regions": [
|
||||
# --- the nape: the broad white collar over the hindneck, stopping short of the black crown
|
||||
{"name": "nape", "debug": (0.95, 0.15, 0.15), "rules": [
|
||||
("z", 0.004, 0.020, 0.070, 0.086), # behind the crown, forward of the mantle
|
||||
("y", 0.266, 0.288, 0.360, 0.400), # upper neck only — never the black bib/throat
|
||||
("ny", -0.60, -0.35, 1.10, 1.20), # skip the under-chin faces
|
||||
]},
|
||||
# --- the shoulder / wing bar: on the flanks only, so the dorsal midline stays black
|
||||
{"name": "wing", "debug": (0.15, 0.9, 0.25), "rules": [
|
||||
("ax", 0.032, 0.044, 0.090, 0.095), # |x| — outer flank, both sides
|
||||
("y", 0.172, 0.196, 0.252, 0.266), # the TOP of the folded wing, not the flank
|
||||
("z", -0.040, -0.020, 0.048, 0.064), # anterior: the primaries stay black
|
||||
]},
|
||||
# --- rump + tail base: white from the lower back into the proximal tail, black tip left
|
||||
{"name": "tailbase", "debug": (0.2, 0.35, 0.98), "rules": [
|
||||
("z", -0.172, -0.156, -0.062, -0.044), # black terminal band survives past -0.16
|
||||
("y", -0.05, 0.0, 0.162, 0.184), # under the back line, so the mantle stays black
|
||||
]},
|
||||
],
|
||||
# the paint: magpie white is a true bright white, faintly warm in sun
|
||||
"paint": (0.955, 0.950, 0.930),
|
||||
}
|
||||
|
||||
SPECS = {"magpie": MAGPIE}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- GLB read / write
|
||||
def glb_read(path):
|
||||
b = open(path, "rb").read()
|
||||
total = struct.unpack("<I", b[8:12])[0]
|
||||
off, chunks = 12, []
|
||||
while off < total:
|
||||
clen, ctype = struct.unpack("<II", b[off:off + 8])
|
||||
chunks.append([ctype, bytearray(b[off + 8:off + 8 + clen])])
|
||||
off += 8 + clen
|
||||
return json.loads(chunks[0][1].decode("utf-8")), chunks
|
||||
|
||||
|
||||
def glb_write(path, J, chunks):
|
||||
js = json.dumps(J, separators=(",", ":")).encode("utf-8")
|
||||
js += b" " * ((4 - len(js) % 4) % 4)
|
||||
bin_ = bytes(chunks[1][1])
|
||||
bin_ += b"\0" * ((4 - len(bin_) % 4) % 4)
|
||||
out = struct.pack("<III", 0x46546C67, 2, 12 + 8 + len(js) + 8 + len(bin_))
|
||||
out += struct.pack("<II", len(js), 0x4E4F534A) + js
|
||||
out += struct.pack("<II", len(bin_), 0x004E4942) + bin_
|
||||
open(path, "wb").write(out)
|
||||
return len(out)
|
||||
|
||||
|
||||
def accessor(J, BIN, idx):
|
||||
a = J["accessors"][idx]
|
||||
bv = J["bufferViews"][a["bufferView"]]
|
||||
dt = {5126: "<f4", 5123: "<u2", 5125: "<u4", 5121: "u1"}[a["componentType"]]
|
||||
n = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4}[a["type"]]
|
||||
arr = np.frombuffer(bytes(BIN), dtype=dt, count=a["count"] * n,
|
||||
offset=bv["byteOffset"] + a.get("byteOffset", 0))
|
||||
return arr.reshape(a["count"], n) if n > 1 else arr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- UV -> 3D rasterizer
|
||||
def rasterize(P, N, UV, tris, size):
|
||||
"""texel -> (position, normal, covered). Barycentric, top-left UV origin (glTF)."""
|
||||
pos = np.zeros((size, size, 3), np.float32)
|
||||
nrm = np.zeros((size, size, 3), np.float32)
|
||||
cov = np.zeros((size, size), bool)
|
||||
px = UV[:, 0] * size - 0.5
|
||||
py = UV[:, 1] * size - 0.5 # v grows DOWN the image in glTF
|
||||
for t in tris:
|
||||
i0, i1, i2 = t
|
||||
x0, y0, x1, y1, x2, y2 = px[i0], py[i0], px[i1], py[i1], px[i2], py[i2]
|
||||
den = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2)
|
||||
if abs(den) < 1e-9:
|
||||
continue
|
||||
# 1-texel conservative pad so island interiors are fully covered
|
||||
cl = max(int(np.floor(min(x0, x1, x2))) - 1, 0)
|
||||
cr = min(int(np.ceil(max(x0, x1, x2))) + 1, size - 1)
|
||||
rt = max(int(np.floor(min(y0, y1, y2))) - 1, 0)
|
||||
rb = min(int(np.ceil(max(y0, y1, y2))) + 1, size - 1)
|
||||
if cr < cl or rb < rt:
|
||||
continue
|
||||
gx, gy = np.meshgrid(np.arange(cl, cr + 1), np.arange(rt, rb + 1))
|
||||
l0 = ((y1 - y2) * (gx - x2) + (x2 - x1) * (gy - y2)) / den
|
||||
l1 = ((y2 - y0) * (gx - x2) + (x0 - x2) * (gy - y2)) / den
|
||||
l2 = 1.0 - l0 - l1
|
||||
m = (l0 >= -0.25) & (l1 >= -0.25) & (l2 >= -0.25) # -0.25 = the pad
|
||||
if not m.any():
|
||||
continue
|
||||
b = np.stack([np.clip(l0[m], 0, 1), np.clip(l1[m], 0, 1), np.clip(l2[m], 0, 1)], 1)
|
||||
b /= b.sum(1, keepdims=True)
|
||||
rr, cc = gy[m], gx[m]
|
||||
pos[rr, cc] = b @ P[[i0, i1, i2]]
|
||||
nrm[rr, cc] = b @ N[[i0, i1, i2]]
|
||||
cov[rr, cc] = True
|
||||
return pos, nrm, cov
|
||||
|
||||
|
||||
def region_mask(spec, pos, nrm, cov):
|
||||
x, y, z = pos[..., 0], pos[..., 1], pos[..., 2]
|
||||
axes = {"x": x, "y": y, "z": z, "ax": np.abs(x),
|
||||
"nx": nrm[..., 0], "ny": nrm[..., 1], "nz": nrm[..., 2]}
|
||||
m = np.ones_like(y)
|
||||
for axis, lo_out, lo_in, hi_in, hi_out in spec["rules"]:
|
||||
m = m * window(axes[axis], lo_out, lo_in, hi_in, hi_out)
|
||||
return m * cov
|
||||
|
||||
|
||||
def dilate(img, mask, cov, rounds=5):
|
||||
"""Push painted colour outward into the bake's UV padding (uncovered texels next to painted
|
||||
ones), so bilinear/mip sampling at an island edge never pulls the old black through."""
|
||||
img = img.copy()
|
||||
done = (mask > 0.02)
|
||||
free = ~cov
|
||||
for _ in range(rounds):
|
||||
n_sum = np.zeros_like(img)
|
||||
n_cnt = np.zeros(img.shape[:2], np.float32)
|
||||
for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
s = np.roll(np.roll(done.astype(np.float32), dy, 0), dx, 1)
|
||||
c = np.roll(np.roll(img, dy, 0), dx, 1)
|
||||
n_sum += c * s[..., None]
|
||||
n_cnt += s
|
||||
grow = free & (~done) & (n_cnt > 0)
|
||||
img[grow] = (n_sum[grow] / n_cnt[grow][..., None])
|
||||
done = done | grow
|
||||
return img
|
||||
|
||||
|
||||
def main():
|
||||
which = sys.argv[1] if len(sys.argv) > 1 else "magpie"
|
||||
debug = "--debug" in sys.argv
|
||||
spec = SPECS[which]
|
||||
src = os.path.join(ROOT, "pipeline", "_normalized", spec["file"])
|
||||
out = sys.argv[sys.argv.index("--out") + 1] if "--out" in sys.argv else src
|
||||
J, chunks = glb_read(src)
|
||||
BIN = chunks[1][1]
|
||||
prim = J["meshes"][0]["primitives"][0]
|
||||
P = accessor(J, BIN, prim["attributes"]["POSITION"])
|
||||
N = accessor(J, BIN, prim["attributes"]["NORMAL"])
|
||||
UV = accessor(J, BIN, prim["attributes"]["TEXCOORD_0"])
|
||||
IDX = accessor(J, BIN, prim["indices"]).reshape(-1, 3)
|
||||
|
||||
ibv = J["bufferViews"][J["images"][0]["bufferView"]]
|
||||
raw = bytes(BIN[ibv["byteOffset"]:ibv["byteOffset"] + ibv["byteLength"]])
|
||||
im = Image.open(io.BytesIO(raw)).convert("RGB")
|
||||
size = im.size[0]
|
||||
a = np.asarray(im).astype(np.float32) / 255.0
|
||||
print(f"{spec['file']}: {len(IDX)} tris · atlas {im.size} {J['images'][0]['mimeType']}")
|
||||
|
||||
pos, nrm, cov = rasterize(P, N, UV, IDX, size)
|
||||
print(f" rasterized: {cov.sum()} / {size*size} texels covered ({100*cov.mean():.1f}%)")
|
||||
|
||||
out_img = a.copy()
|
||||
total = np.zeros(cov.shape, np.float32)
|
||||
for r in spec["regions"]:
|
||||
m = region_mask(r, pos, nrm, cov)
|
||||
total = np.maximum(total, m)
|
||||
col = np.array(r["debug"] if debug else spec["paint"], np.float32)
|
||||
if debug:
|
||||
out_img = out_img * (1 - m[..., None]) + col * m[..., None]
|
||||
else:
|
||||
# keep the bake's own light: its luminance, stretched into a white's shading range
|
||||
lum = a @ np.array([0.2126, 0.7152, 0.0722], np.float32)
|
||||
shade = 0.88 + 0.12 * smoothstep(0.0, 0.28, lum)
|
||||
painted = col * shade[..., None]
|
||||
out_img = out_img * (1 - m[..., None]) + painted * m[..., None]
|
||||
print(f" {r['name']:9} {int((m>0.5).sum()):6d} texels solid, {int((m>0.02).sum()):6d} touched")
|
||||
out_img = dilate(np.clip(out_img, 0, 1), total, cov)
|
||||
|
||||
png = Image.fromarray((np.clip(out_img, 0, 1) * 255 + 0.5).astype(np.uint8))
|
||||
if "--dump-atlas" in sys.argv:
|
||||
png.save(sys.argv[sys.argv.index("--dump-atlas") + 1])
|
||||
buf = io.BytesIO()
|
||||
png.save(buf, "WEBP", quality=88, method=6)
|
||||
new = buf.getvalue()
|
||||
|
||||
# rewrite the image bufferView in place; it is the LAST view in the buffer (verified), so the
|
||||
# only offsets that move are its own length and the buffer's.
|
||||
tail_users = [bv for bv in J["bufferViews"] if bv["byteOffset"] > ibv["byteOffset"]]
|
||||
assert not tail_users, "image bufferView is not last — repack needed"
|
||||
del BIN[ibv["byteOffset"]:]
|
||||
BIN.extend(new)
|
||||
ibv["byteLength"] = len(new)
|
||||
J["buffers"][0]["byteLength"] = len(BIN)
|
||||
n = glb_write(out, J, chunks)
|
||||
print(f" atlas {len(raw)} -> {len(new)} B webp · GLB -> {out} ({n} B)"
|
||||
+ (" [DEBUG COLOURS]" if debug else ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
91
pipeline/view_sheet.py
Normal file
91
pipeline/view_sheet.py
Normal file
@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compose render_views.py output into one labelled contact sheet + a distance strip.
|
||||
|
||||
PY=~/Documents/MODELBEAST/venvs/mflux/bin/python
|
||||
$PY pipeline/view_sheet.py OUT.png "title" "row label" a.png.views.txt ["row 2" b...txt] ...
|
||||
|
||||
One row of views per input (each composited over sky — bird-against-sky is the contrast that
|
||||
matters), then a strip that re-samples the swoop / flank / rear views to 64 / 48 / 32 px and
|
||||
magnifies them NEAREST: a 0.36 m bird at 3-10 m on a 1080-high screen is 50-100 px, so 64 px is
|
||||
the honest test of whether a marking reads and 32 px is the pessimistic one.
|
||||
"""
|
||||
import sys
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SKY = (158, 184, 219)
|
||||
PAD, LBL = 10, 16
|
||||
STRIP = ("swoop", "flank", "rear34")
|
||||
SIZES = (64, 48, 32)
|
||||
CELL = 112
|
||||
|
||||
|
||||
def over_sky(p, bg=SKY):
|
||||
im = Image.open(p).convert("RGBA")
|
||||
out = Image.new("RGB", im.size, bg)
|
||||
out.paste(im, (0, 0), im)
|
||||
return out
|
||||
|
||||
|
||||
def load(path):
|
||||
views = {}
|
||||
for line in open(path):
|
||||
k, v = line.rstrip("\n").split("\t")
|
||||
if k != "LABEL":
|
||||
views[k] = over_sky(v)
|
||||
return views
|
||||
|
||||
|
||||
def main():
|
||||
out_path, title = sys.argv[1], sys.argv[2]
|
||||
rows = []
|
||||
args = sys.argv[3:]
|
||||
extra = None
|
||||
if "--extra" in args: # --extra IMG.png "caption": pasted under the sheet
|
||||
i = args.index("--extra")
|
||||
extra = (args[i + 1], args[i + 2])
|
||||
args = args[:i]
|
||||
for i in range(0, len(args), 2):
|
||||
rows.append((args[i], load(args[i + 1])))
|
||||
w, h = next(iter(rows[0][1].values())).size
|
||||
ncol = max(len(v) for _, v in rows)
|
||||
n_strip = sum(len([n for n in STRIP if n in v]) * len(SIZES) for _, v in rows)
|
||||
ex = Image.open(extra[0]).convert("RGB") if extra else None
|
||||
W = max(PAD + ncol * (w + PAD), PAD + n_strip * (CELL + PAD), (ex.width + 2 * PAD) if ex else 0)
|
||||
H = 20 + len(rows) * (h + LBL + PAD) + LBL + CELL + 18 + PAD
|
||||
if ex:
|
||||
H += LBL + ex.height + PAD
|
||||
sheet = Image.new("RGB", (W, H), (24, 24, 26))
|
||||
d = ImageDraw.Draw(sheet)
|
||||
d.text((PAD, 4), title, fill=(240, 240, 240))
|
||||
y = 22
|
||||
for label, views in rows:
|
||||
d.text((PAD, y), label, fill=(250, 210, 120))
|
||||
y += LBL
|
||||
for i, (n, im) in enumerate(views.items()):
|
||||
x = PAD + i * (w + PAD)
|
||||
sheet.paste(im, (x, y))
|
||||
d.text((x + 4, y + 2), n, fill=(40, 40, 40))
|
||||
y += h + PAD
|
||||
d.text((PAD, y), "AT THE SIZE IT IS ACTUALLY SEEN (rendered px, magnified NEAREST):",
|
||||
fill=(250, 210, 120))
|
||||
y += LBL
|
||||
i = 0
|
||||
for label, views in rows:
|
||||
for n in STRIP:
|
||||
if n not in views:
|
||||
continue
|
||||
for s in SIZES:
|
||||
x = PAD + i * (CELL + PAD)
|
||||
sheet.paste(views[n].resize((s, s), Image.LANCZOS).resize((CELL, CELL), Image.NEAREST),
|
||||
(x, y))
|
||||
d.text((x + 3, y + CELL + 2), f"{label[:9]} {n} {s}px", fill=(175, 175, 175))
|
||||
i += 1
|
||||
if ex:
|
||||
y += CELL + 18
|
||||
d.text((PAD, y), extra[1], fill=(250, 210, 120))
|
||||
sheet.paste(ex, (PAD, y + LBL))
|
||||
sheet.save(out_path)
|
||||
print(f"sheet -> {out_path} {sheet.size}")
|
||||
|
||||
|
||||
main()
|
||||
@ -924,6 +924,33 @@
|
||||
],
|
||||
"height": 1.0,
|
||||
"thumb": "thumbs/procity_street_bin_01.png"
|
||||
},
|
||||
"magpie": {
|
||||
"file": "procity_street_magpie_01.glb",
|
||||
"footprint": [
|
||||
0.15,
|
||||
0.4
|
||||
],
|
||||
"height": 0.36,
|
||||
"thumb": "thumbs/procity_street_magpie_01.png"
|
||||
},
|
||||
"paling_fence": {
|
||||
"file": "procity_street_paling_fence_01.glb",
|
||||
"footprint": [
|
||||
0.28,
|
||||
3.59
|
||||
],
|
||||
"height": 1.79,
|
||||
"thumb": "thumbs/procity_street_paling_fence_01.png"
|
||||
},
|
||||
"water_tank": {
|
||||
"file": "procity_street_water_tank_01.glb",
|
||||
"footprint": [
|
||||
1.73,
|
||||
1.64
|
||||
],
|
||||
"height": 2.19,
|
||||
"thumb": "thumbs/procity_street_water_tank_01.png"
|
||||
}
|
||||
},
|
||||
"fittingAliases": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
Loading…
Reference in New Issue
Block a user