Lane E R39 (6/n): THREE GLBs shipped as GLASS, not one — transmissionFactor 1 stripped, published, sha1-verified, and now GATED. Plus the arcade's three meshes.
Lane C measured the bookshelf's transmission halving interior draws (opshop/hall 191->104, book/hall
101->51, dept/hall 116->116 control). The cause, with vendored line numbers: three.module.js:16209
enters renderTransmissionPass(opaqueObjects, ...) whenever currentRenderList.transmissive is
non-empty, and :16433 re-renders the WHOLE OPAQUE LIST into a render target — every opaque draw
issued twice. Trigger is material.transmission > 0 (:6890), set from the glTF at GLTFLoader:1202.
And at 1.0 it is not 'a bit glassy': :494 does totalDiffuse = mix(totalDiffuse, transmitted.rgb,
material.transmission) — the diffuse term is REPLACED by the backdrop.
I SCANNED ALL 103 GLBs. Three carried it:
procity_fit_bookshelf_01 mtl_10218_Bookshelves_v1 shipped and hurting (C's)
procity_street_longbench_01 lambert3SG NOT WIRED — the 5.86 m arcade bench
the v9 charter names, on my R38
recommend list for Lane B
procity_street_streetlight_01 Streetlight_HighResSG3 NOT WIRED — one per ~18 m of street
The two unwired ones are the worse finding: the street budget is 292/300 with EIGHT draws of margin,
and a pre-pass there costs another ~292, not eight. Armed, not fired.
FIXED SURGICALLY. pipeline/strip_transmission.py rewrites only the JSON chunk and copies BIN through,
printing sha1(BIN) before and after: BIN IDENTICAL on all three, tris/dims/mtl/img/tex unchanged.
KHR_materials_ior deliberately left (ior does not trigger the pre-pass).
PUBLISHED + VERIFIED under John's R22 standing authorization via the passwordless tailnet ingress:
6/6 HTTP 200 off the PUBLIC depot, byte counts exact, sha1 identical, depot copies re-scanned clean.
_published.json 56 -> 59.
GATED, which matters more than the fix: validate_manifest.py now HARD FAILS on transmissionFactor>0
with the three.js line numbers and the fix command in the message; glb_stat.py grew a transmission
column and a loud footer. Nobody looked for three epochs because a bookshelf is obviously not glass.
ALSO: the arcade's three meshes, published and catalogued the same round they were generated —
aframe_board 366 tris (<=16 inst), blade_sign 299 (17 inst), keycutter_sign 498 (1 inst). manifest
furniture 13->16, GLBs 36->39, validate 0 errors 0 warnings. blade_sign ships FLAGGED: its back face
carries TRELLIS-hallucinated art where the concept had no data, which is one more reason the
+0-draw perpendicular-quad answer is the recommendation and this GLB is the fallback.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
0ee7a84710
commit
12eb571357
@ -1,9 +1,52 @@
|
|||||||
# LANE E — cross-lane notes
|
# LANE E — cross-lane notes
|
||||||
|
|
||||||
## Round 39 — the magpie settled, and the arcade measured ⟨v9 39.4⟩
|
## Round 39 — the magpie settled, the arcade measured, and three GLBs that were secretly glass ⟨v9 39.4⟩
|
||||||
|
|
||||||
Full provenance and method: `pipeline/AUDIT.md` § Round 39. Sheets:
|
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`.
|
`docs/shots/laneE/r39_magpie_ab.png` (the ruling picture) · `r39_transmission_fix.png` ·
|
||||||
|
`r39_arcade_skins.png` · `r39_arcade_kit.png`.
|
||||||
|
|
||||||
|
### → LANE C + LANE F + FABLE: THE TRANSMISSION PRE-PASS — **FIXED, PUBLISHED, GATED.** And it was **three** assets, not one.
|
||||||
|
|
||||||
|
Lane C measured that zeroing the bookshelf's transmission halves interior draws. The cause, with
|
||||||
|
vendored line numbers: **three.js runs a transmission PRE-PASS.** `three.module.js:16209` —
|
||||||
|
`if ( transmissiveObjects.length > 0 ) renderTransmissionPass( opaqueObjects, … )`; `:16433` renders
|
||||||
|
the **whole opaque list** into a render target, so **every opaque draw in that scene is issued
|
||||||
|
twice**. The trigger is `material.transmission > 0` and nothing else (`:6890`), set straight from the
|
||||||
|
glTF by `GLTFLoader.js:1202-1204`. And at 1.0 it is not "a bit glassy": `:494`'s
|
||||||
|
`transmission_fragment` does `totalDiffuse = mix( totalDiffuse, transmitted.rgb, material.transmission )`
|
||||||
|
— **the diffuse term is replaced entirely by the refracted backdrop.**
|
||||||
|
|
||||||
|
**I scanned all 103 GLBs in the repo. THREE carried `transmissionFactor: 1`:**
|
||||||
|
|
||||||
|
| file | material | state |
|
||||||
|
|---|---|---|
|
||||||
|
| `procity_fit_bookshelf_01.glb` | `mtl_10218_Bookshelves_v1` | **shipped and hurting** — the one C measured |
|
||||||
|
| `procity_street_longbench_01.glb` | `lambert3SG` | **NOT WIRED** — the 5.86 m arcade/market bench the v9 charter asks for **by name**, and which my own R38 notes call a "true one-liner" for Lane B |
|
||||||
|
| `procity_street_streetlight_01.glb` | `Streetlight_HighResSG3` | **NOT WIRED** — one per ~18 m of street, also on my R38 recommend list |
|
||||||
|
|
||||||
|
> **→ LANE B: the two unwired ones are the worse finding, and they were pointed at you.** The street
|
||||||
|
> budget is **292/300 with eight draws of margin**. A transmission pre-pass on the street does not
|
||||||
|
> cost eight draws, it costs **another ~292**. Both are now fixed on the depot; **if you had a local
|
||||||
|
> copy cached, re-fetch.** No other GLB in the repo carries `KHR_materials_transmission` or
|
||||||
|
> `KHR_materials_volume`.
|
||||||
|
|
||||||
|
**Fixed with `pipeline/strip_transmission.py`** — JSON chunk rewritten, BIN chunk copied through, and
|
||||||
|
the tool prints sha1(BIN) before and after: **`BIN IDENTICAL` on all three.** Tris/dims/materials/
|
||||||
|
images/max-tex unchanged. `KHR_materials_ior` deliberately left (ior does not trigger the pre-pass).
|
||||||
|
Published under the R22 standing authorization and **fetched back off the public depot: 6/6 HTTP 200,
|
||||||
|
bytes exact, sha1 identical**, and the depot copies re-scanned clean. `_published.json` 56 → 59.
|
||||||
|
|
||||||
|
**→ LANE F, the gate you can now write:** `validate_manifest.py` **hard-fails** on
|
||||||
|
`transmissionFactor > 0` for every manifest GLB (message carries the three.js line numbers and the
|
||||||
|
fix command), and `glb_stat.py` grew a **`transmission` column** plus a loud footer. `qa.sh` already
|
||||||
|
runs the validator, so the control is live. C's measurement is the falsifiable arm: re-run the
|
||||||
|
interior draw sweep and `opshop`/hall should sit at ~104, not 191.
|
||||||
|
|
||||||
|
**→ LANE C: neither fix you held back is needed now.** The asset is corrected at source, so `glb.js`
|
||||||
|
needs no `transmission = 0` scrub and no appearance compromise. You were right to hold — it was my
|
||||||
|
asset and my call, and it was a defect, not a style: **a bookshelf, a park bench and a streetlight
|
||||||
|
are not glass.** This was an exporter default that rode in with the source meshes.
|
||||||
|
|
||||||
### → FABLE: THE MAGPIE — **RECOMMENDATION: TAKE THE GLB.** The picture is `r39_magpie_ab.png`.
|
### → FABLE: THE MAGPIE — **RECOMMENDATION: TAKE THE GLB.** The picture is `r39_magpie_ab.png`.
|
||||||
|
|
||||||
@ -113,19 +156,41 @@ Ranked by **value per draw**, because the margin is eight and this is one distri
|
|||||||
| 2 | **`ground-arcade-floor`** | ground skin, 512² | 1 class | **+1 draw town-wide**, **+0 tris** (the 504 m² quad *moves* out of `footGeos`) |
|
| 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** |
|
| 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** |
|
| 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` |
|
| 5 | **`aframe_board`** | GLB **366 tris**, 0.68 × 0.64 m, h 0.890 | ≤16/town ⇒ ≤5,856 tris | +1 draw as ONE town-wide `InstancedMesh` |
|
||||||
| 6 | **`keycutter_sign`** | GLB | 1 | +1 draw for one object — **lowest value in the kit** |
|
| 6 | **`blade_sign`** | GLB **299 tris**, 0.66 × 0.10 m, h 0.549 | 17 ⇒ 5,083 tris | +1 draw — **the fallback for #4, not the answer** |
|
||||||
|
| 7 | **`keycutter_sign`** | GLB **498 tris**, 0.29 × 0.06 m, h 0.599 | 1 | +1 draw for one object — **lowest value in the kit** |
|
||||||
|
|
||||||
|
All three are published and **sha1-verified off the public depot**, in `manifest.json` under
|
||||||
|
`furniture` (13 → 16), and the local-depot mirror is re-staged for `?localdepot=1`. House GLB law
|
||||||
|
clean: metres · +Y up · base origin · 1 mtl / 1 img / 512² WebP · no Draco.
|
||||||
|
|
||||||
|
> **⚠ `blade_sign` ships with a flagged defect.** Its FRONT face is the blank white the house law
|
||||||
|
> requires; its BACK face carries **TRELLIS-hallucinated artwork** invented where the single concept
|
||||||
|
> photo had no data. A blade sign in a 5 m arcade is read from both sides. It is shipped flagged
|
||||||
|
> rather than quietly (the R38 magpie-albedo precedent), and it is one more reason **#4's +0-draw
|
||||||
|
> quad is the recommendation and this GLB is the fallback.** Fix path if wanted: `tint_atlas.py`
|
||||||
|
> already rasterizes UV→position maps, so flat-whiting every texel with `z < 0` is the same pass
|
||||||
|
> that repainted the magpie.
|
||||||
|
|
||||||
**#4 is a refusal with a better answer, and it is the most useful thing in this table.** A blade
|
**#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
|
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
|
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
|
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
|
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
|
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
|
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
|
that; it can only look different. Generated anyway (AUDIT §Round 39 §6) so the option exists, but
|
||||||
**wire the quad first**.
|
**wire the quad first**.
|
||||||
|
|
||||||
|
> The insertion point is the block you already have — `buildings.js:620-631`, immediately after
|
||||||
|
> `const uv = signUV.get(shop.id)`. Today's quad is `sh 0.5 · sy = fh − 0.35 · sw = w×0.92 ·
|
||||||
|
> zs = zf + 0.05`, i.e. spanning **y 2.40–2.90** on a 3.0 m facade. **Do not reuse that y for a
|
||||||
|
> blade**: the awning slab is `AWNING_Y 2.95` × 0.16 thick ⇒ its underside is **2.87**, so today's
|
||||||
|
> sign band already grazes it. A blade wants **centre y ≈ 2.35, height ≈ 0.45 ⇒ 2.13–2.58** — clear
|
||||||
|
> under the slab and well over EYE 1.62. Project it **~0.7 m** from the facade (`z` from `zf+0.05`
|
||||||
|
> outward), fixed `x` at a lot edge: 0.7 m into a 2.50 m half-lane leaves **1.8 m clear** each side,
|
||||||
|
> which is more clearance than the awning posts currently leave (§ the post finding above).
|
||||||
|
|
||||||
**#2 and #3's `use` values name slots that do not exist yet — deliberately.** R38 recorded `gravel`
|
**#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
|
(`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`,
|
selected by nothing *on purpose and with the price printed*: the arcade cannot ride `footpath`,
|
||||||
|
|||||||
BIN
docs/shots/laneE/r39_arcade_kit.png
Normal file
BIN
docs/shots/laneE/r39_arcade_kit.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 498 KiB |
BIN
docs/shots/laneE/r39_transmission_fix.png
Normal file
BIN
docs/shots/laneE/r39_transmission_fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 600 KiB |
@ -997,3 +997,106 @@ photographic grounds already in the pool, seam measured and recorded rather than
|
|||||||
|
|
||||||
**$0 spent.** No cloud key touched. The hardyards set stays REJECTED on provenance (R38), and the
|
**$0 spent.** No cloud key touched. The hardyards set stays REJECTED on provenance (R38), and the
|
||||||
3GOD depot stays a read-only archive.
|
3GOD depot stays a read-only archive.
|
||||||
|
|
||||||
|
### 6. THE THREE MESHES — measured, published, byte-verified
|
||||||
|
|
||||||
|
`flux_local` → `bg_remove_local` (RMBG-2.0) → `trellis2_mlx` (1024_cascade, metal baker, 500 k bake
|
||||||
|
cap) → `normalize.py` → `bake_lowpoly.py`. **3/3 first attempt, 0 retries, 0 OOM-kills.**
|
||||||
|
|
||||||
|
| id | tris | footprint (m) | height (m) | mtl/img/tex | instances the rule implies | sha1 (local == depot) |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| `aframe_board` | **366** | 0.68 × 0.64 | 0.890 | 1 / 1 / 512² | **≤16/town** (arcade ~4 + v9's unmarked cue ≤12) ⇒ ≤5,856 tris | `2aa9732d23c7…` |
|
||||||
|
| `blade_sign` | **299** | 0.66 × 0.10 | 0.549 | 1 / 1 / 512² | **17** (one per arcade shop) ⇒ 5,083 tris | `210e270c22ec…` |
|
||||||
|
| `keycutter_sign` | **498** | 0.29 × 0.06 | 0.599 | 1 / 1 / 512² | **1** | `6f22995d6402…` |
|
||||||
|
|
||||||
|
House GLB law verified on all three: metres · +Y up · base origin · **1 material / 1 image / 512²
|
||||||
|
WebP** · no Draco. Decimation floors, again confirming R38's finding that collapse cannot reach
|
||||||
|
these budgets and the bake must: 443,014 → 104,997 (normalize) → **366** (bake) · 490,612 → 94,202 →
|
||||||
|
**299** · 493,916 → 28,940 → **498**.
|
||||||
|
|
||||||
|
**⚠ `blade_sign` ships with a defect, flagged not hidden.** Its FRONT face is the blank white the
|
||||||
|
house law requires, but its BACK face carries **TRELLIS-hallucinated artwork** (a plant, a globe,
|
||||||
|
scattered shapes) invented where the single concept photograph had no data. A blade sign in a 5 m
|
||||||
|
arcade is read from both sides, so this matters — and it is one more reason the **+0-draw
|
||||||
|
perpendicular-quad answer (§3 item 4) is the recommendation and this GLB is the fallback.** Fix
|
||||||
|
path if it is ever wanted, stated not done: the R38 `tint_atlas.py` machinery already rasterizes
|
||||||
|
UV→position maps, so flat-whiting every texel with `z < 0` is the same pass that painted the magpie.
|
||||||
|
|
||||||
|
**⚠ AND THE OPERATOR'S TAIL IS NOT AN OUTLIER — it looks like memory pressure.** Wall clock:
|
||||||
|
**516.3 s · 538.7 s · 176.8 s.** R38 reported a single 489 s job against a 128 s median and called
|
||||||
|
it an unexplained tail. Here the **first two** jobs — the two that followed a `flux_local` skin
|
||||||
|
batch and a `flux_local` concept batch in the same session — took ~3× the third, which ran after
|
||||||
|
everything else had exited. Same operator, same params, same box, no error, monotonically improving.
|
||||||
|
**Hypothesis, not proof: the tail is resident MPS memory from the image model, not the mesh model.**
|
||||||
|
Worth one controlled A/B in a later round; until then, plan a mesh batch as a *cold* run.
|
||||||
|
|
||||||
|
### 7. THE TRANSMISSION PRE-PASS — three GLBs shipped as glass (R39, from Lane C's measurement)
|
||||||
|
|
||||||
|
Lane C measured that zeroing `bookshelf`'s transmission halves interior draws. The cause is not
|
||||||
|
subtle once named: **three.js runs a transmission PRE-PASS.**
|
||||||
|
|
||||||
|
> `three.module.js:16209` — `if ( transmissiveObjects.length > 0 ) renderTransmissionPass( opaqueObjects, … )`
|
||||||
|
> `three.module.js:16433` — `renderTransmissionPass` renders the **whole opaque list** into a
|
||||||
|
> `WebGLRenderTarget`. **Every opaque draw in that scene is issued twice.**
|
||||||
|
> `three.module.js:6890` — the trigger is `material.transmission > 0`, nothing else.
|
||||||
|
> `GLTFLoader.js:1202-1204` — `materialParams.transmission = extension.transmissionFactor`.
|
||||||
|
> `three.module.js:494` (`transmission_fragment`) — `totalDiffuse = mix( totalDiffuse, transmitted.rgb,
|
||||||
|
> material.transmission )`, so at **1.0 the diffuse is replaced entirely by the refracted backdrop**:
|
||||||
|
> the asset is not "slightly glassy", it *is* glass.
|
||||||
|
|
||||||
|
**Lane C's numbers:** `opshop`/hall **191 → 104 (−46%)** · `opshop`/wide 142 → 80 · `book`/hall
|
||||||
|
**101 → 51 (−50%)** · `dept`/hall 116 → 116 (control — no bookshelf, no change).
|
||||||
|
|
||||||
|
**I scanned all 103 GLBs** in `pipeline/_normalized`, `web/assets/models`, `web/assets` and
|
||||||
|
`pipeline/packs` for `KHR_materials_transmission` / `KHR_materials_volume`. **Three carried it —
|
||||||
|
not one:**
|
||||||
|
|
||||||
|
| file | material | state before R39 |
|
||||||
|
|---|---|---|
|
||||||
|
| `procity_fit_bookshelf_01.glb` | `mtl_10218_Bookshelves_v1` | **SHIPPED and hurting** — the one C measured |
|
||||||
|
| `procity_street_longbench_01.glb` | `lambert3SG` (+ ior 1) | **NOT WIRED** — the 5.86 m arcade/market bench the v9 charter names, and LANE_E_NOTES R38 lists as a "true one-liner" for Lane B |
|
||||||
|
| `procity_street_streetlight_01.glb` | `Streetlight_HighResSG3` (+ ior 1) | **NOT WIRED** — one per ~18 m of street, also on R38's recommend list |
|
||||||
|
|
||||||
|
**The two unwired ones are the worse finding.** They are STREET assets, both recommended to Lane B
|
||||||
|
by this lane's own notes, and the street budget is **292/300 with eight draws of margin**. A
|
||||||
|
transmission pre-pass on the street does not cost eight draws — it costs **another ~292**. They were
|
||||||
|
armed, not fired. No other GLB in the repo carries either extension.
|
||||||
|
|
||||||
|
**The fix: `pipeline/strip_transmission.py`.** Dependency-free GLB surgery — rewrite the JSON chunk,
|
||||||
|
copy the BIN chunk through untouched, and **print the sha1 of the BIN chunk before and after** so
|
||||||
|
"geometry unchanged" is a checked claim. All three: `BIN IDENTICAL`. Tris, dims, material and image
|
||||||
|
counts and max texture size are unchanged (5000 / 1172 / 4999). `KHR_materials_ior` is deliberately
|
||||||
|
**left alone** — ior does not trigger the pre-pass, and removing more than the defect widens the
|
||||||
|
diff for no measured gain.
|
||||||
|
|
||||||
|
**Appearance:** before/after through the same rig, `docs/shots/laneE/r39_transmission_fix.png`. In
|
||||||
|
Blender/EEVEE the difference is small; in three.js it is the whole diffuse term (see the shader line
|
||||||
|
above). Either way the direction is not a trade: **a bookshelf, a park bench and a streetlight are
|
||||||
|
not glass.** This was an exporter default that rode in with the source meshes and has been doubling
|
||||||
|
interior draws for however many epochs it has shipped.
|
||||||
|
|
||||||
|
**Published and verified**, under John's R22 standing authorization, through the passwordless tailnet
|
||||||
|
ingress (`GOD3_DEPOT=http://100.94.195.115:8788`; no secret read, no cookie). Then fetched back off
|
||||||
|
the **public** host and compared: **6/6 HTTP 200, byte counts exact, sha1 identical**, and the depot
|
||||||
|
copies re-scanned — **`clean, nothing to do` on all six.**
|
||||||
|
|
||||||
|
| file | HTTP | bytes (local == depot) | sha1 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `procity_fit_bookshelf_01.glb` | 200 | 356 636 | `1769af492fbf9338…` |
|
||||||
|
| `procity_street_longbench_01.glb` | 200 | 56 600 | `2813e81c6bb1feb5…` |
|
||||||
|
| `procity_street_streetlight_01.glb` | 200 | 156 328 | `9c5ec45d4d861db5…` |
|
||||||
|
| `procity_street_aframe_board_01.glb` | 200 | 88 572 | `2aa9732d23c72c2e…` |
|
||||||
|
| `procity_street_blade_sign_01.glb` | 200 | 64 196 | `210e270c22ec4dd6…` |
|
||||||
|
| `procity_street_keycutter_sign_01.glb` | 200 | 79 708 | `6f22995d6402a509…` |
|
||||||
|
|
||||||
|
`_published.json` **56 → 59**; drift check green. `build_manifest.py` re-run (furniture 13 → 16,
|
||||||
|
GLBs 36 → 39); **`validate_manifest.py`: 0 errors, 0 warnings.**
|
||||||
|
|
||||||
|
**AND IT IS NOW UNGATEABLE-PAST, which matters more than the fix.**
|
||||||
|
- `glb_stat.py` grew a **`transmission` column** and prints a loud block when any file has it.
|
||||||
|
- `validate_manifest.py` **HARD FAILS** on `transmissionFactor > 0` for every manifest GLB, with the
|
||||||
|
three.js line numbers in the message and the fix command. Nobody looked for three epochs because a
|
||||||
|
bookshelf is obviously not glass; a gate is the only thing that makes "nobody looked" impossible.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|||||||
@ -33,16 +33,19 @@
|
|||||||
"procity_fit_record_crate_01.glb",
|
"procity_fit_record_crate_01.glb",
|
||||||
"procity_fit_wire_shelf_01.glb",
|
"procity_fit_wire_shelf_01.glb",
|
||||||
"procity_fit_work_table_01.glb",
|
"procity_fit_work_table_01.glb",
|
||||||
|
"procity_street_aframe_board_01.glb",
|
||||||
"procity_street_bench_01.glb",
|
"procity_street_bench_01.glb",
|
||||||
"procity_street_bench_01_hi.glb",
|
"procity_street_bench_01_hi.glb",
|
||||||
"procity_street_bench_modern_01.glb",
|
"procity_street_bench_modern_01.glb",
|
||||||
"procity_street_bench_wood_01.glb",
|
"procity_street_bench_wood_01.glb",
|
||||||
"procity_street_bin_01.glb",
|
"procity_street_bin_01.glb",
|
||||||
"procity_street_bin_01_hi.glb",
|
"procity_street_bin_01_hi.glb",
|
||||||
|
"procity_street_blade_sign_01.glb",
|
||||||
"procity_street_bus_shelter_01.glb",
|
"procity_street_bus_shelter_01.glb",
|
||||||
"procity_street_bus_shelter_01_hi.glb",
|
"procity_street_bus_shelter_01_hi.glb",
|
||||||
"procity_street_food_cart_01.glb",
|
"procity_street_food_cart_01.glb",
|
||||||
"procity_street_food_cart_01_hi.glb",
|
"procity_street_food_cart_01_hi.glb",
|
||||||
|
"procity_street_keycutter_sign_01.glb",
|
||||||
"procity_street_longbench_01.glb",
|
"procity_street_longbench_01.glb",
|
||||||
"procity_street_magpie_01.glb",
|
"procity_street_magpie_01.glb",
|
||||||
"procity_street_novelty_record_01.glb",
|
"procity_street_novelty_record_01.glb",
|
||||||
|
|||||||
53
pipeline/_r39_results.json
Normal file
53
pipeline/_r39_results.json
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"file": "procity_street_aframe_board_01.glb",
|
||||||
|
"tri_before": 104997,
|
||||||
|
"tri_after": 366,
|
||||||
|
"size_m": [
|
||||||
|
0.678,
|
||||||
|
0.89,
|
||||||
|
0.641
|
||||||
|
],
|
||||||
|
"footprint": [
|
||||||
|
0.68,
|
||||||
|
0.64
|
||||||
|
],
|
||||||
|
"thumb": "web/assets/thumbs/procity_street_aframe_board_01.png",
|
||||||
|
"out": "pipeline/_normalized/procity_street_aframe_board_01.glb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "procity_street_blade_sign_01.glb",
|
||||||
|
"tri_before": 94202,
|
||||||
|
"tri_after": 299,
|
||||||
|
"size_m": [
|
||||||
|
0.657,
|
||||||
|
0.549,
|
||||||
|
0.101
|
||||||
|
],
|
||||||
|
"footprint": [
|
||||||
|
0.66,
|
||||||
|
0.1
|
||||||
|
],
|
||||||
|
"thumb": "web/assets/thumbs/procity_street_blade_sign_01.png",
|
||||||
|
"out": "pipeline/_normalized/procity_street_blade_sign_01.glb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "procity_street_keycutter_sign_01.glb",
|
||||||
|
"tri_before": 28939,
|
||||||
|
"tri_after": 498,
|
||||||
|
"size_m": [
|
||||||
|
0.288,
|
||||||
|
0.599,
|
||||||
|
0.058
|
||||||
|
],
|
||||||
|
"footprint": [
|
||||||
|
0.29,
|
||||||
|
0.06
|
||||||
|
],
|
||||||
|
"thumb": "web/assets/thumbs/procity_street_keycutter_sign_01.png",
|
||||||
|
"out": "pipeline/_normalized/procity_street_keycutter_sign_01.glb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
@ -24,6 +24,10 @@ GIG_RESULTS = os.path.join(ROOT, "pipeline", "_gig_results.json") # roun
|
|||||||
# `_r38_results.PENDING_PUBLISH.json`. ONE STEP lands them: publish, then rename it to
|
# `_r38_results.PENDING_PUBLISH.json`. ONE STEP lands them: publish, then rename it to
|
||||||
# `_r38_results.json` and re-run this script. See AUDIT.md R38 §4 and LANE_E_NOTES R38.
|
# `_r38_results.json` and re-run this script. See AUDIT.md R38 §4 and LANE_E_NOTES R38.
|
||||||
R38_RESULTS = os.path.join(ROOT, "pipeline", "_r38_results.json") # round-38 wave-1 props
|
R38_RESULTS = os.path.join(ROOT, "pipeline", "_r38_results.json") # round-38 wave-1 props
|
||||||
|
# [R39] the arcade kit — aframe_board / blade_sign / keycutter_sign. All three are PUBLISHED and
|
||||||
|
# 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
|
||||||
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
|
OUT = os.path.join(ROOT, "web", "assets", "manifest.json")
|
||||||
AUDIO = os.path.join(ROOT, "web", "assets", "audio") # round-11 audio pack
|
AUDIO = os.path.join(ROOT, "web", "assets", "audio") # round-11 audio pack
|
||||||
DEPOT = "https://digalot.fyi/3god"
|
DEPOT = "https://digalot.fyi/3god"
|
||||||
@ -294,7 +298,7 @@ def build():
|
|||||||
fittings, furniture = {}, {}
|
fittings, furniture = {}, {}
|
||||||
# cube_shelf dropped: horizontal node-scale distortion (6.3x8.4m footprint) — see AUDIT.md
|
# cube_shelf dropped: horizontal node-scale distortion (6.3x8.4m footprint) — see AUDIT.md
|
||||||
DROP = {"procity_fit_cube_shelf_01.glb"}
|
DROP = {"procity_fit_cube_shelf_01.glb"}
|
||||||
for results_file in (RESULTS, PROPS_RESULTS, GIG_RESULTS, R38_RESULTS):
|
for results_file in (RESULTS, PROPS_RESULTS, GIG_RESULTS, R38_RESULTS, R39_RESULTS):
|
||||||
if not os.path.exists(results_file):
|
if not os.path.exists(results_file):
|
||||||
continue
|
continue
|
||||||
for r in json.load(open(results_file)).get("results", []):
|
for r in json.load(open(results_file)).get("results", []):
|
||||||
|
|||||||
@ -140,9 +140,24 @@ def stat(path):
|
|||||||
tex_sizes.append(wh)
|
tex_sizes.append(wh)
|
||||||
exts = gltf.get("extensionsUsed", []) or []
|
exts = gltf.get("extensionsUsed", []) or []
|
||||||
draco = any("draco" in e.lower() for e in exts)
|
draco = any("draco" in e.lower() for e in exts)
|
||||||
|
# [R39] TRANSMISSION IS A DRAW-BUDGET PROPERTY, NOT A LOOK. three.js runs a transmission
|
||||||
|
# PRE-PASS — `renderTransmissionPass(opaqueObjects, …)` at three.module.js:16433, entered from
|
||||||
|
# :16209 whenever `currentRenderList.transmissive.length > 0` — which re-renders the whole
|
||||||
|
# OPAQUE list into a render target, so every draw in that scene is issued TWICE. Three PROCITY
|
||||||
|
# GLBs shipped with `transmissionFactor: 1` and nobody looked, because a bookshelf, a bench and
|
||||||
|
# a streetlight are not glass. Lane C measured the bookshelf's at −46%/−50% of interior draws.
|
||||||
|
# It is reported here so it can never hide in a material block again.
|
||||||
|
transmission = []
|
||||||
|
for i, m in enumerate(gltf.get("materials", [])):
|
||||||
|
ex = m.get("extensions") or {}
|
||||||
|
t = ex.get("KHR_materials_transmission")
|
||||||
|
if t and t.get("transmissionFactor", 0) > 0:
|
||||||
|
transmission.append({"material": i, "name": m.get("name"),
|
||||||
|
"factor": t.get("transmissionFactor")})
|
||||||
return {
|
return {
|
||||||
"file": os.path.basename(path),
|
"file": os.path.basename(path),
|
||||||
"tris": tris,
|
"tris": tris,
|
||||||
|
"transmission": transmission,
|
||||||
"dims_m": dims, # x,y,z extent in file units (hopefully metres)
|
"dims_m": dims, # x,y,z extent in file units (hopefully metres)
|
||||||
"nodes": len(gltf.get("nodes", [])),
|
"nodes": len(gltf.get("nodes", [])),
|
||||||
"meshes": len(meshes),
|
"meshes": len(meshes),
|
||||||
@ -208,17 +223,26 @@ def main():
|
|||||||
if as_json:
|
if as_json:
|
||||||
print(json.dumps(rows, indent=2))
|
print(json.dumps(rows, indent=2))
|
||||||
return
|
return
|
||||||
hdr = f"{'file':38} {'tris':>7} {'dims (m) x,y,z':>22} {'mtl':>3} {'img':>3} {'maxtex':>6} draco"
|
hdr = (f"{'file':38} {'tris':>7} {'dims (m) x,y,z':>22} {'mtl':>3} {'img':>3} {'maxtex':>6} "
|
||||||
|
f"draco transmission")
|
||||||
print(hdr)
|
print(hdr)
|
||||||
print("-" * len(hdr))
|
print("-" * len(hdr))
|
||||||
|
bad = 0
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if "error" in r:
|
if "error" in r:
|
||||||
print(f"{r['file']:38} ERROR: {r['error']}")
|
print(f"{r['file']:38} ERROR: {r['error']}")
|
||||||
continue
|
continue
|
||||||
d = r["dims_m"]
|
d = r["dims_m"]
|
||||||
dstr = f"{d[0]:.2f},{d[1]:.2f},{d[2]:.2f}"
|
dstr = f"{d[0]:.2f},{d[1]:.2f},{d[2]:.2f}"
|
||||||
|
tx = r.get("transmission") or []
|
||||||
|
bad += len(tx)
|
||||||
|
txs = ("*** " + ",".join(f"{t['name']}={t['factor']}" for t in tx) + " ***") if tx else "-"
|
||||||
print(f"{r['file']:38} {r['tris']:>7} {dstr:>22} {r['materials']:>3} "
|
print(f"{r['file']:38} {r['tris']:>7} {dstr:>22} {r['materials']:>3} "
|
||||||
f"{r['images']:>3} {r['max_tex']:>6} {'YES' if r['draco'] else '-'}")
|
f"{r['images']:>3} {r['max_tex']:>6} {'YES' if r['draco'] else '-':5} {txs}")
|
||||||
|
if bad:
|
||||||
|
print(f"\n*** {bad} material(s) carry KHR_materials_transmission > 0. three.js will run a "
|
||||||
|
f"transmission PRE-PASS (three.module.js:16209/:16433) and issue EVERY opaque draw in "
|
||||||
|
f"that scene TWICE. Fix with: python3 pipeline/strip_transmission.py <file> ***")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
107
pipeline/strip_transmission.py
Normal file
107
pipeline/strip_transmission.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""PROCITY Lane E — strip_transmission.py (R39, the transmission pre-pass)
|
||||||
|
|
||||||
|
Remove `KHR_materials_transmission` from a GLB **without touching one byte of geometry**.
|
||||||
|
|
||||||
|
WHY THIS IS NOT A COSMETIC FIX. three.js renders a **transmission pre-pass** — a second full render
|
||||||
|
of the opaque scene into a render target — as soon as ANY material in the scene has
|
||||||
|
`transmission > 0` (`WebGLRenderer`'s transmissive-object list). Every opaque draw call in that
|
||||||
|
scene is therefore issued **twice**. Lane C measured it in R39 by zeroing the bookshelf's:
|
||||||
|
|
||||||
|
opshop/hall 191 -> 104 draws (-46%) · opshop/wide 142 -> 80
|
||||||
|
book/hall 101 -> 51 draws (-50%) · dept/hall 116 -> 116 (control, no bookshelf)
|
||||||
|
|
||||||
|
Three PROCITY assets carry `transmissionFactor: 1`, and only one of them has ever been wired:
|
||||||
|
|
||||||
|
procity_fit_bookshelf_01.glb mtl_10218_Bookshelves_v1 SHIPPED — the one C measured
|
||||||
|
procity_street_longbench_01.glb lambert3SG + ior 1 NOT WIRED — the 5.86 m arcade bench
|
||||||
|
procity_street_streetlight_01.glb Streetlight_HighResSG3 + ior 1 NOT WIRED — one per ~18 m of street
|
||||||
|
|
||||||
|
The two unwired ones are the worse finding. They are STREET assets, LANE_E_NOTES R38 recommends both
|
||||||
|
to Lane B, and the street budget is 292/300 with **eight draws of margin** — a transmission pre-pass
|
||||||
|
there does not cost eight draws, it costs another ~292. Nobody looked because a bookshelf, a bench
|
||||||
|
and a streetlight are not glass; this is an exporter default that rode in with the source meshes.
|
||||||
|
|
||||||
|
python3 pipeline/strip_transmission.py FILE.glb [FILE ...] [--in-place] [--dry-run]
|
||||||
|
|
||||||
|
The JSON chunk is rewritten and the BIN chunk is copied through untouched — the tool prints the
|
||||||
|
sha1 of the BIN chunk before and after so "geometry unchanged" is a checked claim, not a promise.
|
||||||
|
`KHR_materials_ior` is deliberately LEFT ALONE: ior alone does not trigger the pre-pass, and
|
||||||
|
removing more than the defect widens the diff for no measured gain.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
|
||||||
|
EXT = "KHR_materials_transmission"
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
d = open(path, "rb").read()
|
||||||
|
assert d[:4] == b"glTF", f"{path}: not a GLB"
|
||||||
|
off, gltf, binc = 12, None, b""
|
||||||
|
while off < 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(path, gltf, binc):
|
||||||
|
j = json.dumps(gltf, separators=(",", ":")).encode("utf-8")
|
||||||
|
j += b" " * ((4 - len(j) % 4) % 4)
|
||||||
|
b = binc + b"\0" * ((4 - len(binc) % 4) % 4)
|
||||||
|
total = 12 + 8 + len(j) + (8 + len(b) if b else 0)
|
||||||
|
out = bytearray()
|
||||||
|
out += b"glTF" + struct.pack("<II", 2, total)
|
||||||
|
out += struct.pack("<II", len(j), 0x4E4F534A) + j
|
||||||
|
if b:
|
||||||
|
out += struct.pack("<II", len(b), 0x004E4942) + b
|
||||||
|
open(path, "wb").write(bytes(out))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
files = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||||
|
dry = "--dry-run" in sys.argv
|
||||||
|
for f in files:
|
||||||
|
gltf, binc = read(f)
|
||||||
|
before = hashlib.sha1(binc).hexdigest()
|
||||||
|
hits = []
|
||||||
|
for i, m in enumerate(gltf.get("materials", [])):
|
||||||
|
ex = m.get("extensions") or {}
|
||||||
|
if EXT in ex:
|
||||||
|
hits.append((i, m.get("name"), ex[EXT]))
|
||||||
|
del ex[EXT]
|
||||||
|
if not ex:
|
||||||
|
m.pop("extensions", None)
|
||||||
|
if not hits:
|
||||||
|
print(f"{os.path.basename(f)}: clean, nothing to do")
|
||||||
|
continue
|
||||||
|
# prune extensionsUsed/Required only if no material still declares it
|
||||||
|
still = any(EXT in (m.get("extensions") or {}) for m in gltf.get("materials", []))
|
||||||
|
for key in ("extensionsUsed", "extensionsRequired"):
|
||||||
|
if not still and EXT in (gltf.get(key) or []):
|
||||||
|
gltf[key] = [e for e in gltf[key] if e != EXT]
|
||||||
|
if not gltf[key]:
|
||||||
|
del gltf[key]
|
||||||
|
for i, name, val in hits:
|
||||||
|
print(f"{os.path.basename(f)}: material[{i}] '{name}' — removed {EXT} {val}")
|
||||||
|
if dry:
|
||||||
|
print(" (--dry-run, not written)")
|
||||||
|
continue
|
||||||
|
write(f, gltf, binc)
|
||||||
|
_, binc2 = read(f)
|
||||||
|
after = hashlib.sha1(binc2).hexdigest()
|
||||||
|
ok = "BIN IDENTICAL" if after == before else "*** BIN CHANGED ***"
|
||||||
|
print(f" wrote {f} {os.path.getsize(f)} B {ok} sha1(BIN) {after[:12]}")
|
||||||
|
if after != before:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
main()
|
||||||
@ -49,10 +49,47 @@ def check_skin(file):
|
|||||||
err(f"skin missing: {file}")
|
err(f"skin missing: {file}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_transmission(local_path, file):
|
||||||
|
"""[R39] HARD FAIL on KHR_materials_transmission > 0.
|
||||||
|
|
||||||
|
This is a draw-budget check wearing a material's clothes. three.js runs a transmission PRE-PASS
|
||||||
|
— `renderTransmissionPass(opaqueObjects, …)`, three.module.js:16433, entered at :16209 whenever
|
||||||
|
`currentRenderList.transmissive.length > 0` — which re-renders the entire OPAQUE list into a
|
||||||
|
render target, so EVERY draw in that scene is issued twice. Lane C measured the effect of one
|
||||||
|
such material (the bookshelf's): opshop/hall 191→104 draws, book/hall 101→51, against a control
|
||||||
|
room with no bookshelf at 116→116. Three GLBs shipped with `transmissionFactor: 1` because a
|
||||||
|
bookshelf, a park bench and a streetlight are obviously not glass and so nobody ever looked.
|
||||||
|
A gate is the only thing that makes "nobody looked" impossible. Fix: pipeline/strip_transmission.py
|
||||||
|
"""
|
||||||
|
if not os.path.isfile(local_path):
|
||||||
|
return # depot-only asset: nothing to parse here
|
||||||
|
try:
|
||||||
|
import struct
|
||||||
|
d = open(local_path, "rb").read()
|
||||||
|
if d[:4] != b"glTF":
|
||||||
|
return
|
||||||
|
off, g = 12, None
|
||||||
|
while off < 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"))
|
||||||
|
break
|
||||||
|
off += 8 + clen
|
||||||
|
for i, mat in enumerate(g.get("materials", []) if g else []):
|
||||||
|
t = (mat.get("extensions") or {}).get("KHR_materials_transmission")
|
||||||
|
if t and t.get("transmissionFactor", 0) > 0:
|
||||||
|
err(f"{file}: material[{i}] '{mat.get('name')}' has transmissionFactor "
|
||||||
|
f"{t['transmissionFactor']} — three.js will run a transmission PRE-PASS and "
|
||||||
|
f"issue every opaque draw in that scene TWICE. Run pipeline/strip_transmission.py")
|
||||||
|
except Exception as e: # a parse failure is not a licence to skip the check
|
||||||
|
warn(f"{file}: could not read materials for the transmission check ({str(e)[:60]})")
|
||||||
|
|
||||||
|
|
||||||
def check_glb(entry, depot):
|
def check_glb(entry, depot):
|
||||||
file = entry["file"]
|
file = entry["file"]
|
||||||
local = os.path.join(NORM, file)
|
local = os.path.join(NORM, file)
|
||||||
on_disk = os.path.isfile(local)
|
on_disk = os.path.isfile(local)
|
||||||
|
check_transmission(local, file)
|
||||||
live = head_ok(f"{depot}/a/{file}")
|
live = head_ok(f"{depot}/a/{file}")
|
||||||
if STRICT_DEPOT and not live:
|
if STRICT_DEPOT and not live:
|
||||||
err(f"GLB not live on depot: {file}")
|
err(f"GLB not live on depot: {file}")
|
||||||
|
|||||||
@ -966,6 +966,33 @@
|
|||||||
],
|
],
|
||||||
"height": 2.19,
|
"height": 2.19,
|
||||||
"thumb": "thumbs/procity_street_water_tank_01.png"
|
"thumb": "thumbs/procity_street_water_tank_01.png"
|
||||||
|
},
|
||||||
|
"aframe_board": {
|
||||||
|
"file": "procity_street_aframe_board_01.glb",
|
||||||
|
"footprint": [
|
||||||
|
0.68,
|
||||||
|
0.64
|
||||||
|
],
|
||||||
|
"height": 0.89,
|
||||||
|
"thumb": "thumbs/procity_street_aframe_board_01.png"
|
||||||
|
},
|
||||||
|
"blade_sign": {
|
||||||
|
"file": "procity_street_blade_sign_01.glb",
|
||||||
|
"footprint": [
|
||||||
|
0.66,
|
||||||
|
0.1
|
||||||
|
],
|
||||||
|
"height": 0.549,
|
||||||
|
"thumb": "thumbs/procity_street_blade_sign_01.png"
|
||||||
|
},
|
||||||
|
"keycutter_sign": {
|
||||||
|
"file": "procity_street_keycutter_sign_01.glb",
|
||||||
|
"footprint": [
|
||||||
|
0.29,
|
||||||
|
0.06
|
||||||
|
],
|
||||||
|
"height": 0.599,
|
||||||
|
"thumb": "thumbs/procity_street_keycutter_sign_01.png"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fittingAliases": {
|
"fittingAliases": {
|
||||||
|
|||||||
BIN
web/assets/thumbs/procity_street_aframe_board_01.png
Normal file
BIN
web/assets/thumbs/procity_street_aframe_board_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 50 KiB |
BIN
web/assets/thumbs/procity_street_blade_sign_01.png
Normal file
BIN
web/assets/thumbs/procity_street_blade_sign_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
BIN
web/assets/thumbs/procity_street_keycutter_sign_01.png
Normal file
BIN
web/assets/thumbs/procity_street_keycutter_sign_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
Loading…
Reference in New Issue
Block a user