diff --git a/docs/LANES/LANE_E_NOTES.md b/docs/LANES/LANE_E_NOTES.md index cfa9f75..7665d3b 100644 --- a/docs/LANES/LANE_E_NOTES.md +++ b/docs/LANES/LANE_E_NOTES.md @@ -1,5 +1,146 @@ # LANE E — cross-lane notes +## Round 39 — the magpie settled, and the arcade measured ⟨v9 39.4⟩ + +Full provenance and method: `pipeline/AUDIT.md` § Round 39. Sheets: +`docs/shots/laneE/r39_magpie_ab.png` (the ruling picture) · `docs/shots/laneE/r39_arcade_kit.png`. + +### → FABLE: THE MAGPIE — **RECOMMENDATION: TAKE THE GLB.** The picture is `r39_magpie_ab.png`. + +Both birds shot through the **same rig** (`render_views.py --eevee --noemit`), same sky and sun, +same lens, each framed to its own extent so neither gets a size advantage, **emission stripped from +both** — and B's bird is **B's own code**, not a port: `pipeline/dump_bird.mjs` imports +`web/js/world/magpie.js` unmodified (bare `three` resolved to the repo's own vendored build through +a node hook) and dumps `birdGeometry()` vertex for vertex. + +**Three findings, in the order they decide it.** + +1. **The player is looking at the PERCH, not the swoop.** Simulated on magpie.js's own clock + (`--sim`: DEFEND_R 34 m, drawn out to 74.8 m, COOLDOWN 5.5 s, SWOOP_T 1.25 s, RETURN_T 1.9 s; + 60 s walk at WALK 4.6 m/s passing 4 m off the perch): the bird is drawn in **54.1 % of frames**, + and **of those it is perched 80.5 % · swooping 7.7 % · returning 11.8 %**. The swoop is the + memory. The perch is the picture — and **the perch is the pose E's asset already is** (a standing + bird with legs), while B's perch is the flight geometry squashed 0.42× in X. +2. **At the sizes it is seen, E reads as a magpie and B reads as a dark aeroplane.** 64 / 48 / 32 px, + three views, still and under two linear motion-blur levels. E is pied and unmistakable at 64 and + 48 and still black-and-white at 32, and survives a 25 % smear. B is a grey dash at every size in + every view except top-down. +3. **And the mechanism is geometric, which is why no re-tint could have saved it.** Three of B's + four white marks cannot be seen from where the player stands: + - the **wing bars are 3 mm ABOVE the wing planes** (`0.048` vs `0.045`, both horizontal after + `rotateX(-π/2)`, both DoubleSide) — so from **underneath**, which is where the player always + is, the black wing occludes its own white bar. `magpie.js`'s header says *"that black-and-white + flash is the entire read"*; the flash is on the roof of the wing. + - the **nape box** (`0.052×0.014×0.055` at y 0.06, z −0.10) sits **inside the head sphere** except + for a ~10 mm sliver — at z = −0.10 the head's surface is at y 0.0728 and the box tops out at 0.065. + - the **rump box** (y 0.045–0.065 at z 0.035–0.165) is **inside the body ellipsoid** over its + front half (body top at z 0.035 is y 0.0744). + +**Cost of the swap, stated plainly:** **+712 triangles at one instance.** Draws are a **wash** — +both are one town-wide `InstancedMesh` at count 0 or 1. The GLB adds +1 fetch, +1 material, +1 512² +texture, and **it cannot replace the procedural bird**: `?noassets` and `?classic` still need it. So +this is `furniture.js`'s **use-if-ready second path** at `birdGeometry()` / `birdMaterial()`, not a +substitution. It is also **no longer a blind probe** — R26's ruling was about probing for a file +nobody had shipped; `magpie` has been in `manifest.json` and on the public depot since R38 §7a. + +**The one thing that could flip it, said out loud:** B's bird flaps (the `wing` wind weight) and E's +GLB is rigid, and a spread-wing silhouette is more bird-shaped in flight than a folded one. That is +worth 7.7 % of the frames. If Fable weighs the swoop above the perch, the answer changes — and in +that case the honest outcome is **both**, which costs two paths and a second draw, and I do not +recommend it. + +### → LANE B: three asks, and one of them costs zero triangles + +**ASK 1 — `magpie.js` has two stale tri comments.** `:9` and `:123` both say **154 triangles**; +`:42`'s table says 182 and **182 is what I measured** — by running your own `birdGeometry()` (216 +verts, index length 546) and again by `glb_stat.py` on the export. The arithmetic agrees: sphere(8,6) +80 + sphere(6,5) 48 + cone(5) 10 + 3 boxes 36 + 4 planes 8 = **182**. *Your file, your edit.* + +**ASK 2 — the white bar is on the wrong side of the wing, and moving it costs +0 tris.** See finding +3 above. `magpie.js:147-148` puts each bar at `y 0.048` on a wing whose plane is at `y 0.045`. The +player is **always below** the bird (perch height 4.32 m, EYE 1.62 m). Moving the bar to **y ≈ 0.042** +makes it visible from below and hidden from above — the correct trade at zero cost. A bar on **both** +faces is +8 tris total (4 bars × 2). Same class of fix for the nape (raise y 0.06 → ~0.082, or slide +it back to z ≈ −0.06 where a nape actually is) and the rump (raise y 0.055 → ~0.075). **This matters +whatever Fable rules on the GLB, because `?classic` and `?noassets` will show the procedural bird +forever.** + +**ASK 3 — `skins.js`'s `GROUND_FALLBACK` has no entry for either arcade surface.** `arcade-floor` +and `arcade-roof` match none of the nine regexes, so both fall to the generic `#6b6b64` grey under +`?noassets` or a 404 — and the house law is that the town looks right with zero assets. Suggested, +in your vocabulary and prepended like the R38 entries: `[/arcade-floor|terrazzo/, '#c3b6a2']` and +`[/arcade-roof|ceiling/, '#ded7c6']`. + +### → LANE B: THE ARCADE, MEASURED — the numbers before the assets + +Reproduce any of this with **`node pipeline/arcade_measure.mjs`** (reads Lane A's own `generatePlan`; +nothing below is estimated). Five seeds. + +| | measured | +|---|---| +| lane | **ONE edge, `kind:'arcade'`, 42.00 m × 5 m**, 2 blocks (one per side) | +| shops | **16–17** (17 at seed 20261990 and 20260101, 16 at 7/123456/999) | +| frontage | 3.00–4.94 m, **mean 3.87–4.05 m** · depth 5.10–7.99 m · 4–11 of them two-storey | +| walkable | lot faces at ±2.50 m ⇒ **5.00 m** between shopfronts | +| roof | `AWNING_DEPTH 2.2` from each face ⇒ the two slabs reach ±0.30 m ⇒ **already roofed except a 0.60 m slot** down the centreline, at **y 2.95 m** | +| floor | `ground.js`'s arcade branch emits **one** footpath quad, 42.00 × 12.00 m = **504 m²** — the biggest single surface in the district, wearing `footSkin` | + +> **⚠ THE AWNING POSTS ARE IN THE MIDDLE OF THE ARCADE.** `buildings.js:643` plants them at +> `d/2 + AWNING_DEPTH − 0.2` = **2.00 m out from each lot face**, i.e. **±0.50 m from the +> centreline** — **two rows of posts 1.00 m apart down a 5.00 m lane.** They carry no collider +> (`colliders.push(lotCollider(lot))` is the only one), so the charter's 4.99 m walkable stands and +> nothing traps the player; it just *reads* as a colonnade planted in its own doorway. `AWNING_DEPTH +> 2.2` was sized against `ground.js`'s 3.5 m `FOOT` band, not a 2.5 m half-lane. **A real arcade's +> roof spans; it has no posts.** The cheapest fix is a district-keyed depth (or no posts at all for +> `district.kind === 'arcade'`), and it is +0 draws either way — two `boxMats` entries fewer per lot. + +**The synthetic-only rule, as a code rule.** `plan_osm.js:305` is one unconditional +`addDistrict('mainstreet')`, so **no cache town has an arcade DISTRICT**. Real towns *do* have +**1,102 arcade-KIND edges = 39 km of ordinary suburban footpath**. Key every piece of arcade +dressing on `district.kind`, never on `edge.kind` (the v9 cut list, restated with the number). + +### → LANE B: the kit, sized by `tris × the instance count YOUR placement rule implies` + +Ranked by **value per draw**, because the margin is eight and this is one district on one town. + +| # | asset | form | instances | cost | +|---|---|---|---|---| +| 1 | **`facade-shuttered`** | facade skin, 768×595 | 1–2 shops | **+0 draws, +0 tris**, 1 of the **14 free** facade-atlas slots | +| 2 | **`ground-arcade-floor`** | ground skin, 512² | 1 class | **+1 draw town-wide**, **+0 tris** (the 504 m² quad *moves* out of `footGeos`) | +| 3 | **`ground-arcade-roof`** | ground skin, 512² | 1 quad | **+1 draw in the 1–2 chunks that hold arcade lots**, **+2 tris** | +| 4 | **hanging blade signage** | ***no asset — see below*** | 17 | **+0 draws, +0 assets, +4 tris/shop = 68** | +| 5 | **`aframe_board`** | GLB | ≤16/town | +1 draw as ONE town-wide `InstancedMesh` | +| 6 | **`keycutter_sign`** | GLB | 1 | +1 draw for one object — **lowest value in the kit** | + +**#4 is a refusal with a better answer, and it is the most useful thing in this table.** A blade +sign does not need a mesh or a texture. `buildings.js` already draws every shop's sign into a +per-chunk 512×168 canvas atlas cell and pushes ONE quad into the merged `signQuads` mesh. **Push a +SECOND quad, perpendicular to the facade, with the same `signUV.get(shop.id)` rect** and you get a +projecting sign carrying the shop's real name for **+0 draws** (it rides the mesh that already +exists) and **+2 tris single-sided / +4 back-to-back** — **68 triangles for all 17 shops.** The +bracket is two `boxMats` boxes, which is also +0 draws. A GLB blade sign at 17 instances cannot beat +that; it can only look different. Generated anyway (see AUDIT §Round 39) so the option exists, but +**wire the quad first**. + +**#2 and #3's `use` values name slots that do not exist yet — deliberately.** R38 recorded `gravel` +(`use:'verge'`) and `reddust` (`use:'outback'`) as selected by nothing *by accident*. These two are +selected by nothing *on purpose and with the price printed*: the arcade cannot ride `footpath`, +because that is one town-wide merged mesh and re-using it would lay terrazzo over every footpath in +the town. Both must also be **classic-gated** — `?classic=1` does not move on dressing. + +### → EVERY LATER ASSET ROUND: the sizing law, now standing in AUDIT + +Folded into `pipeline/AUDIT.md` as a **standing instruction**, not a round note: *get the instance +count from the consuming lane BEFORE generating anything that tiles, edges, or repeats per-lot.* +R39 is the first round that ran it as a script (`pipeline/arcade_measure.mjs`) rather than as a +conversation, and it changed the deliverable — item #4 above stopped being an asset. + +**And a second clause the arcade added to it: for a SKIN the budget is not bytes, it is the SLOT.** +`skins.js:facadeAtlasUV` has **36 slots (6×6)** and past 36 it `console.warn`s and **reuses one** — +so a 37th facade skin does not fail, it silently repaints an existing shop. Measured: the synthetic +uses **22 distinct facade skins** (21 at seed 123456), so there are **14 free**. That is the real +budget line for a facade skin, and it is the number to ask for before generating one. + ## Round 38 — v8 wave 1: the asset wave ⟨v8 WAVE 1⟩ Full provenance, measurements and method: `pipeline/AUDIT.md` § Round 38. Contact sheets: diff --git a/docs/shots/laneE/r39_arcade_skins.png b/docs/shots/laneE/r39_arcade_skins.png new file mode 100644 index 0000000..8aa3112 Binary files /dev/null and b/docs/shots/laneE/r39_arcade_skins.png differ diff --git a/pipeline/AUDIT.md b/pipeline/AUDIT.md index 26aa2e2..d8d74f3 100644 --- a/pipeline/AUDIT.md +++ b/pipeline/AUDIT.md @@ -830,3 +830,162 @@ and in the manifest — harmless, provenance-recorded, available if a low-count **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.** + +## Round 39 — the magpie A/B, and the arcade's kit (v9 39.4) + +*Every number below was measured on this box today. `$0` — no cloud key touched, no third-party +asset or image entered the pipeline, and the 3GOD depot was **not** read as a pool (it stays the +read-only archive R38 declared it).* + +### 0. STANDING INSTRUCTION — the sizing law, promoted out of the round notes + +**Before generating anything that tiles, edges, repeats per lot, or is placed by a rule rather than +by hand: get the instance count from the CONSUMING LANE. The budget is `tris × that count`, not +"≤5 k like a prop".** R38 paid for this with `paling_fence` — a good 1,121-tri panel that became a +**rejected asset** at 5 runs × 181 lots = 905 instances = 1.01 M triangles. Two clauses were added +in R39 and both are now part of the law: + +1. **Draws are counted per CLASS, and where the class lives decides everything.** A GLB cannot ride + `boxMats` (BoxGeometry, one shared untextured shell material), so every distinct GLB geometry is + +1 draw. As ONE town-wide `InstancedMesh` (`magpie.js`'s pattern) that is +1 **total**; as a + per-chunk furniture class it is +1 × up to **25 live chunks**, which against 8 draws of margin is + a breach, not a cost. Ask which one before generating. +2. **For a SKIN the budget is not bytes, it is the SLOT.** `skins.js:facadeAtlasUV` has **36 slots + (6×6)**; past 36 it `console.warn`s and **reuses one**, i.e. a 37th facade skin silently + repaints an existing shop. Measured: the synthetic uses **22 distinct facade skins** (21 at seed + 123456) ⇒ **14 free**. That is the real budget line for a facade skin. + +The law is now runnable rather than conversational: **`node pipeline/arcade_measure.mjs`** reads +Lane A's own `generatePlan` and prints the counts. It changed this round's deliverable — one of the +four commissioned assets stopped being an asset (§3, item 4). + +### 1. THE MAGPIE A/B — rendering Lane B's own geometry, not a re-implementation of it + +The R38 debt was *"two magpies exist and nobody has seen them side by side."* The trap in settling +it is that a hand-ported strawman proves nothing, so B's bird is not ported: + +- **`pipeline/dump_bird.mjs`** imports `web/js/world/magpie.js` **unmodified** and calls the real + `createMagpie()`. The bare `three` / `three/addons/` specifiers its imports use are resolved to + the repo's **own vendored build** (`web/vendor/three.module.js`) through a `node:module` + `registerHooks` resolver — the importmap from `index.html:22-25`, as a node resolver. The vertices + dumped are the vertices the browser gets. + **Measured: 182 triangles, 216 verts, bbox 0.680 × 0.166 × 0.670 m.** The arithmetic agrees + independently: sphere(8,6) = 8×(2·6−2) = 80 · sphere(6,5) = 48 · cone(5) = 5 side + 5 cap = 10 · + 3 boxes = 36 · 4 planes = 8 ⇒ **182**. `magpie.js:9` and `:123` say **154** and are stale. +- **`pipeline/bird_to_glb.py`** wraps the dump as a GLB **in E's frame** (glTF +Y up, head at +Z; + three's frame is +Y up, nose at −Z, and Blender's `export_yup` writes `glTF = (Bx, Bz, −By)`, so + the Blender vertex is `(−xt, zt, yt)` — a 180° yaw, det +1, winding preserved). Without that the + two rows of the sheet would be shot from opposite ends of the bird. `--fold` applies the perch + pose (`place(..., folded)` scales the instance X by 0.42). `glb_stat.py` re-measures **182**. +- **`pipeline/render_views.py --noemit`** strips emission from **both** candidates, and this is the + correction that makes the picture usable at all. `normalize.py` gives every baked GLB + `emissiveFactor 0.28` **with an `emissiveTexture`** — 0.28 × its own albedo, so blacks stay black + and whites brighten. A vertex-coloured mesh **cannot express that in glTF** (COLOR_0 multiplies + base colour only), and Blender's exporter silently writes a **flat 0.28**, which lifted B's black + bird to grey and produced a first sheet that was worthless in E's favour. Off both sides. The + consequence, stated on the sheet: **a baked GLB reads slightly better in game than it does here** — + the safe direction to be wrong in. +- **`pipeline/view_sheet.py --ab`** lays the distance strip out as a **grid** (one line per + candidate, same view and same pixel size stacked) and repeats it under **linear motion blur** — + a true N-position average, not a gaussian, with the smear length scaled to the SUBJECT's on-screen + width. Two levels, both derived: **25 %** (the approach) and **100 %** (the pass — the swoop + covers 34 m in 1.25 s = 27.3 m/s = 0.45 m per 60 fps frame ≈ one body length). + +**The measurement that decided it** (`dump_bird.mjs --sim`, magpie.js's own clock: DEFEND_R 34 m, +drawn out to 74.8 m, COOLDOWN 5.5 s, SWOOP_T 1.25 s, RETURN_T 1.9 s; a 60 s walk at WALK 4.6 m/s +passing 4 m off the perch): + +> the bird is drawn in **1,949 of 3,600 frames (54.1 %)** — and of those it is +> **perched 1,569 (80.5 %) · swooping 150 (7.7 %) · returning 230 (11.8 %)**. + +**And the mechanism, which is why no re-tint could have saved B's bird.** Three of its four white +marks cannot be seen from where the player stands: + +| mark | `magpie.js` | why it does not read | +|---|---|---| +| wing bars ×2 | `:147-148`, plane at **y 0.048** | the wing plane is at **y 0.045** and both are horizontal (`rotateX(-π/2)`) and DoubleSide ⇒ **from underneath the black wing occludes its own white bar**, and the player is always underneath (perch 4.32 m, EYE 1.62 m) | +| nape | `:140`, box `0.052×0.014×0.055` at y 0.06, z −0.10 | at z −0.10 the head sphere's surface is **y 0.0728** and the box tops out at **0.065** ⇒ inside it but for a ~10 mm sliver | +| rump | `:141`, box y 0.045–0.065, z 0.035–0.165 | at z 0.035 the body ellipsoid's top is **y 0.0744** ⇒ the front half is inside the body | + +**VERDICT — Lane E recommends TAKING THE GLB**, with the cost stated: **+712 tris at one instance**, +draws a **wash** (both are one town-wide `InstancedMesh` at count 0 or 1), **+1 fetch / +1 material / ++1 512² texture**, and it is a **use-if-ready second path**, not a substitution — `?noassets` and +`?classic` still need the procedural bird. No blind probe is implied: `magpie` has been in +`manifest.json` and on the public depot since R38 §7a, so this is `furniture.js`'s existing shape. +Sheet: **`docs/shots/laneE/r39_magpie_ab.png`**. Fable rules. + +### 2. THE ARCADE, MEASURED (`pipeline/arcade_measure.mjs`, 5 seeds, off Lane A's `generatePlan`) + +| | measured | +|---|---| +| lane | ONE edge `kind:'arcade'`, **42.00 m × 5 m**, 2 blocks | +| shops | **16–17** (17 at 20261990 / 20260101; 16 at 7 / 123456 / 999) | +| frontage | 3.00–4.94 m, mean **3.87–4.05 m**; depth 5.10–7.99 m; 4–11 two-storey | +| walkable | lot faces at ±2.50 m ⇒ **5.00 m** | +| roof | `AWNING_DEPTH 2.2` from each face ⇒ slabs reach ±0.30 m ⇒ **roofed except a 0.60 m centre slot**, ceiling **y 2.95 m** | +| floor | `ground.js`'s arcade branch: ONE quad **42.00 × 12.00 m = 504 m²**, wearing `footSkin` | +| ⚠ posts | `buildings.js:643` at `d/2 + 2.2 − 0.2` ⇒ **±0.50 m from the centreline: two post rows 1.00 m apart down a 5.00 m lane.** No collider, so it walks — it just reads as a colonnade in its own doorway. → Lane B | + +**Synthetic-only as a code rule:** `plan_osm.js:305` is one unconditional `addDistrict('mainstreet')` +⇒ no cache town has an arcade DISTRICT, while the corpus has **1,102 arcade-KIND edges = 39 km of +ordinary suburban footpath**. Key on `district.kind`, never `edge.kind`. + +### 3. THE KIT — ranked by value per draw, because the margin is eight + +| # | asset | form | instances | cost | licence | +|---|---|---|---|---|---| +| 1 | `facade-shuttered` | facade skin **768×595** | 1–2 shops | **+0 draws, +0 tris**, 1 of 14 free atlas slots | 🟢 | +| 2 | `ground-arcade-floor` | ground skin **512²** | 1 class | **+1 draw town-wide**, **+0 tris** (the 504 m² quad *moves* class) | 🟢 | +| 3 | `ground-arcade-roof` | ground skin **512²** | 1 quad | **+1 draw in the 1–2 arcade chunks**, **+2 tris** | 🟢 | +| 4 | hanging blade signage | **no asset — a refusal with a better answer** | 17 | **+0 draws, +0 assets, +4 tris/shop = 68** | — | + +**Item 4 is the most useful line in the table.** `buildings.js` already draws each shop's sign into +a per-chunk 512×168 canvas atlas cell and pushes ONE quad into the merged `signQuads` mesh. Push a +**second, perpendicular quad with the same `signUV.get(shop.id)` rect** and the arcade gets 17 +projecting signs **carrying each shop's real name**, riding a mesh that already exists: **+0 draws, ++2 tris single-sided / +4 back-to-back = 68 triangles for all 17.** The bracket is two `boxMats` +boxes, also +0 draws. A GLB blade sign at 17 instances cannot beat that; it can only look different. + +**Aspect is not decoration.** `skins.js` paints every facade into a **square** 341 px atlas slot, and +arcade lots are 3.87 × 3.0 m ⇒ **1.29:1**, against the main-street pool's 1.78:1 — a 38 % horizontal +stretch if shot to the pool. `facade-shuttered` is therefore shot at 1.29:1 and lands nearly +undistorted (1024×768 → square → 1.29:1 quad ≈ 1.03 net). Recorded; not retro-fitted to the other 48. + +**`use` values that name no slot — this time on purpose.** R38 recorded `gravel`/`reddust` as +selected by nothing *by accident*. `arcade-floor` and `arcade-ceiling` are selected by nothing *with +the price printed*: the arcade cannot ride `footpath` (one town-wide merged mesh — re-using it lays +terrazzo over every footpath in town), so each needs its own class and each is classic-gated. + +### 4. `pipeline/seamless_tile.py` — and the guard it needed + +A tiled floor is the one skin class where the repeat seam is a *mistake* rather than a texture: +grass hides a joint, terrazzo does not, and the arcade floor is 504 m². The tool finds the pattern's +pitch by autocorrelating the mean-absolute column/row gradient (a grout line is a gradient spike) and +crops to a whole number of periods — arithmetic instead of feathering, which would smear the grout. + +**It needed a guard, because the naive version made things worse and said nothing.** Measured: + +| skin | pitch found (corr) | seam before → after | kept | +|---|---|---|---| +| `ground-arcade-roof` | x 212 px (r 0.25) | **x 26.7 → 8.3** | ✅ x cropped to 848 | +| `ground-arcade-floor` | x 196 (r 0.26) / y 108 (r 0.30) | **x 27.9 → 31.9**, y 29.6 → 33.4 | ❌ rejected, passed through | + +So the crop is now a **proposal that has to beat the number it is trying to improve, per axis**, and +the tool prints `NO IMPROVEMENT AVAILABLE, passed through` when it cannot. **The finding underneath +it:** a diffusion-drawn tiled floor is **not periodic enough to cut on** — the model draws tiles with +perspective and irregular spacing, so the autocorrelation peaks weakly (r ≈ 0.26) and at the wrong +lag. **Fix path for a later round, stated not done:** synthesise the terrazzo procedurally in numpy +(the `gen_audio.py` doctrine — original by construction, exactly periodic, 🟢), instead of +photographing it with a diffusion model. The shipped floor is the flux one, consistent with the ten +photographic grounds already in the pool, seam measured and recorded rather than hidden. + +### 5. LICENCE — every R39 asset lands 🟢 + +| class | count | zone | method | +|---|---|---|---| +| skins | 3 | **🟢 original** | `flux_local` / FLUX.2-klein-4B (Apache-2.0, ungated), on-device | +| meshes | see §6 | **🟢 CC0-equivalent** (`AUDIT.md:101` doctrine) | `flux_local` concept → `bg_remove_local` (RMBG-2.0) → `trellis2_mlx` (TRELLIS.2-4B) → `normalize.py` → `bake_lowpoly.py` | +| tooling | 4 files | — | `dump_bird.mjs`, `bird_to_glb.py`, `arcade_measure.mjs`, `seamless_tile.py` — all ours | + +**$0 spent.** No cloud key touched. The hardyards set stays REJECTED on provenance (R38), and the +3GOD depot stays a read-only archive. diff --git a/pipeline/batch_bake_r39.json b/pipeline/batch_bake_r39.json new file mode 100644 index 0000000..cd0e719 --- /dev/null +++ b/pipeline/batch_bake_r39.json @@ -0,0 +1,26 @@ +[ + { + "src": "pipeline/_normalized/procity_street_aframe_board_01.glb", + "id": "aframe_board", + "category": "street", + "voxel": 0.012, + "tris": 400, + "img": 512 + }, + { + "src": "pipeline/_normalized/procity_street_blade_sign_01.glb", + "id": "blade_sign", + "category": "street", + "voxel": 0.010, + "tris": 300, + "img": 512 + }, + { + "src": "pipeline/_normalized/procity_street_keycutter_sign_01.glb", + "id": "keycutter_sign", + "category": "street", + "voxel": 0.008, + "tris": 500, + "img": 512 + } +] diff --git a/pipeline/batch_r39.json b/pipeline/batch_r39.json new file mode 100644 index 0000000..89238c0 --- /dev/null +++ b/pipeline/batch_r39.json @@ -0,0 +1,23 @@ +[ + { + "src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/aframe-board.glb", + "id": "aframe_board", + "category": "street", + "height_m": 0.9, + "tris": 400 + }, + { + "src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/blade-sign.glb", + "id": "blade_sign", + "category": "street", + "height_m": 0.55, + "tris": 300 + }, + { + "src": "/Users/m3ultra/Documents/procity/pipeline/.genprops/keycutter-sign.glb", + "id": "keycutter_sign", + "category": "street", + "height_m": 0.6, + "tris": 500 + } +] diff --git a/pipeline/skin_sheet.py b/pipeline/skin_sheet.py new file mode 100644 index 0000000..dd3d43b --- /dev/null +++ b/pipeline/skin_sheet.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""PROCITY Lane E — skin_sheet.py (R39) + +Contact sheet for 2-D skins that answers the two questions a thumbnail cannot: **does it tile**, and +**what aspect is it actually displayed at**. R38 shipped four grounds and only afterwards recorded +that the eight flux-era ones are 512x279 and stretch under a square UV tile — that is the class of +defect this sheet exists to catch before a harvest, not after. + + PY=~/Documents/MODELBEAST/venvs/mflux/bin/python + $PY pipeline/skin_sheet.py OUT.png "title" FILE.jpg "caption" [FILE "caption" ...] [--note "line" ...] + +Each row: the skin as shipped, a 3x3 wrap preview (what RepeatWrapping does to it), and the measured +wrap-seam error - mean |difference| between the opposite edges, 0-255, printed as a number rather +than left to the eye. +""" +import os +import sys +import textwrap +import numpy as np +from PIL import Image, ImageDraw + +PAD, LBL, TILE = 12, 15, 220 + + +def seam(a): + x = float(np.abs(a[:, 0].astype(np.float32) - a[:, -1].astype(np.float32)).mean()) + y = float(np.abs(a[0].astype(np.float32) - a[-1].astype(np.float32)).mean()) + return x, y + + +def main(): + out_path, title = sys.argv[1], sys.argv[2] + args = sys.argv[3:] + notes = [] + if "--note" in args: + i = args.index("--note") + notes, args = args[i + 1:], args[:i] + rows = [(args[i], args[i + 1]) for i in range(0, len(args), 2)] + W = PAD + TILE + PAD + TILE + PAD + 700 + H = 26 + len(rows) * (TILE + LBL + PAD) + PAD + LBL * (sum(len(n) // 110 + 2 for n in notes) + 1) + sheet = Image.new("RGB", (W, H), (24, 24, 26)) + d = ImageDraw.Draw(sheet) + d.text((PAD, 6), title, fill=(240, 240, 240)) + y = 26 + for path, cap in rows: + im = Image.open(path).convert("RGB") + a = np.asarray(im) + sx, sy = seam(a) + # as shipped, letterboxed into a square cell so a non-square skin LOOKS non-square + cell = Image.new("RGB", (TILE, TILE), (40, 40, 44)) + s = im.copy() + s.thumbnail((TILE, TILE), Image.LANCZOS) + cell.paste(s, ((TILE - s.width) // 2, (TILE - s.height) // 2)) + sheet.paste(cell, (PAD, y)) + # 3x3 wrap preview + t = Image.new("RGB", (im.width * 3, im.height * 3)) + for i in range(3): + for j in range(3): + t.paste(im, (i * im.width, j * im.height)) + sheet.paste(t.resize((TILE, TILE), Image.LANCZOS), (PAD + TILE + PAD, y)) + x0 = PAD + 2 * (TILE + PAD) + d.text((x0, y + 2), os.path.basename(path), fill=(250, 210, 120)) + d.text((x0, y + 2 + LBL), f"{im.width}x{im.height} {im.width / im.height:.2f}:1 " + f"{os.path.getsize(path)} B wrap-seam x={sx:.1f} y={sy:.1f} (0-255)", + fill=(180, 180, 180)) + for k, line in enumerate(textwrap.wrap(cap, 96)): + d.text((x0, y + 2 + LBL * (2 + k)), line, fill=(215, 215, 215)) + d.text((PAD + 3, y + TILE + 1), "as shipped", fill=(150, 150, 150)) + d.text((PAD + TILE + PAD + 3, y + TILE + 1), "3x3 wrap (RepeatWrapping)", fill=(150, 150, 150)) + y += TILE + LBL + PAD + for line in notes: + for part in textwrap.wrap(line, 150): + d.text((PAD, y), part, fill=(190, 190, 190)) + y += LBL + sheet.crop((0, 0, W, min(sheet.height, y + PAD))).save(out_path) + print(f"skin sheet -> {out_path}") + + +main()