From 433bf77a78bb1caa466f4cafd9ee78532d2346b1 Mon Sep 17 00:00:00 2001 From: type-two Date: Mon, 27 Jul 2026 13:37:36 +1000 Subject: [PATCH] The [GLB] hop, closed: headless Blender ortho-render turns TRELLIS meshes into props MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tools/glb_to_sprite.py completes SPACES.md's 3D route — fixed ortho camera fitted to the mesh bounds, flat light, transparent film, straight into props_import. A/B'd on one pool table: TRELLIS wins for objects with volume (worn felt, real pockets, rails), flux stays for flat things. Rake is 60 not 90 — straight down turns a fridge into a grey box. Co-Authored-By: Claude Fable 5 --- LANEHANDOVER.md | 41 +++++++++++ docs/SPACES.md | 26 +++++++ public/props/poolTable.png | Bin 1278 -> 1848 bytes tools/glb_to_sprite.py | 139 +++++++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 tools/glb_to_sprite.py diff --git a/LANEHANDOVER.md b/LANEHANDOVER.md index 3e17a5d..d081732 100644 --- a/LANEHANDOVER.md +++ b/LANEHANDOVER.md @@ -2048,3 +2048,44 @@ caller already got, and the ID photo does not breathe. twice, so every frame breathed 2px instead of 1. Also fixed the band check to read the ORIGINAL y — applying the breath first let a rect fall into the shift band because it had just inhaled. + +## SESSION — FABLE-SOLO-24 · 2026-07-22 + +**Branch:** main (solo, Fable) · **Gate:** lint ✓ build ✓ test ✓ (783 tests) +**Deployed** + verified. + +### Furniture audit — the answer was no +Counted props per space against the map. The **dunnies were the biggest room in +the venue (17x14 tiles) with ONE prop in it** — and the stall minigame happens +there. Pool corner had only the table; the bar had 4 props across 31 tiles of +counter. Nine new kinds generated and placed: urinal / handDryer / cubicleDoor / +mopBucket (dunnies), cueRack (pool), barFridge x2 + iceWell (bar), patioHeater +(yard), truss x2 (over the dance floor). 37 generated props live. + +### The prop pipeline is now settled by an A/B, and the [GLB] hop EXISTS +`tools/glb_to_sprite.py` — the missing half of SPACES.md's [GLB] route. Runs +Blender **headless** (no addon, no GUI, no human): imports the mesh, fits an +ORTHO camera to its bounding box so any mesh at any scale frames identically, +flat 3-sun lighting (props are lit by the game's own LIGHTS, so baked shadows +would fight the room), transparent film, then hands the PNG straight to +props_import.py like any flux raw. + +**Verdict from the A/B (same pool table, both routes): TRELLIS wins for volume.** +Worn felt, real pockets, rails and legs, clean alpha, consistent light — vs +flux's flat green rectangle. Rule recorded in SPACES.md: flux for FLAT things +(posters, decals, signage), TRELLIS for objects with VOLUME (furniture, +machines, fridges). + +### Two gotchas that cost time, recorded so they do not again +1. **`trellis2_mlx` takes `asset_id` as a TOP-LEVEL job field**, not inside + `params`. Nested, it fails with a flat "no input image". The verified shape + is in `~/Documents/MESHGOD/scripts/mb_recon.py`. +2. **Blender 5.x moved EEVEE's identifier** and `RenderEngine.__subclasses__()` + contains entries without `bl_idname` (HydraRenderEngine). Assign the engine + in a try/except chain instead of probing the registry. + +### House style, learned the hard way +**Props are raked three-quarter top-down, NOT strict orthographic.** flux gave a +front-on fridge; the strict-overhead re-roll was physically correct and visually +dead — a grey box, because the glowing bottles that identify a fridge are on its +front. Kept the readable one. `glb_to_sprite.py --rake 60` encodes this. diff --git a/docs/SPACES.md b/docs/SPACES.md index d04c2bc..08c00e3 100644 --- a/docs/SPACES.md +++ b/docs/SPACES.md @@ -113,3 +113,29 @@ palette/density once. *(flux params for all [MB] jobs: `model flux2-klein-4b, steps 4`; put "no text, no lettering" in every prompt EXCEPT the posters, where mangled text reads as authentic gig-poster grot.)* + + +## Prop pipeline — settled 2026-07-22 (A/B'd, not guessed) + +Two routes, both free and unlimited on MODELBEAST. Pick by object, not by habit. + +**[MB] flux direct** — `flux_local` → `tools/props_import.py`. Fast (~9s). +Use for FLAT things: posters, decals, graffiti, signage, floor markings. + +**[GLB] TRELLIS → Blender** — `flux_local` (product shot) → `trellis2_mlx` +(`asset_id` is a TOP-LEVEL job field, NOT inside `params`) → +`tools/glb_to_sprite.py ` → sprite + manifest, headless, no +GUI. Use for objects with real VOLUME: tables, fridges, machines, furniture. + +Why the split, from the A/B (same pool table, both routes): flux picks its own +camera every generation — the same prompt style gave a perfectly top-down +urinal and a front-on fridge in one batch, and mismatched camera angles never +read as one room. The mesh route renders every prop from one fixed ortho +camera, and TRELLIS carries detail flux flattens: worn felt, real pockets, +rails and legs, clean alpha. + +**RAKE = 60°, and it is not negotiable upward.** The venue's props are +three-quarter top-down, not straight down. Re-rolled from directly overhead, a +bar fridge is a grey rectangle — physically correct and visually dead, because +the glowing bottles that say "fridge" are on its front. 60° keeps the top +surface dominant while leaving enough face to identify the object at 32px. diff --git a/public/props/poolTable.png b/public/props/poolTable.png index 1f07167cceb8796594f5917bf83311f846a979cf..5be91a52f729ebc2af9dd1d1c21ce1574a10dca9 100644 GIT binary patch delta 1834 zcmV+_2i5rg3AhfBBYy_}Nkl-oTJ6vT{?SqlJ@rg;vUd7Mz4_W?|hd$K_{FVE92+XFgubqg8hu?`q3 zY+N_%0oOa{&(I$)|0aKRW`xk2m*;71Ym;uw&b4rSFw>{Cr<-)+QZru|L-nJrNFNyB zd%pf`(PMCDV}FY;1H5>5iVpx>?WwR~+yzIiTjy!1Y9waHERX$R&10+&Z6NF<_yFgA zdr1)X+Ou8ybbdB46ED0rOTX8iYUhcVCH^5YM9#~Yh=!i1XPTbZYtOx|>$x9H(1s10 zf+TUsdvaFy`bc;LpZzDI+88>xFi9_Zmh9V;M2A9mdVdHT|be~e=vtAXZ}TudeqTM+d+i0^`Y z9Sj~o3&NbM!nn+$4a@`1ioPp4>7JGbG}$=0V1Hbs$;3M1i$MU)K=Xv|Q>k_l+{L5{ zzz7}(qC`--n@twPoA!Lm!4u}JW;rA*Ouv>RY6egzR1KoWuZGx*G1kEx2vrlx%_?U? zaki3L5d`ItsHE}UVa?iCUttFqAtTW>_Xr^KWGRS3Fwh<~`8Uw+lrn2|jF2jL(6h;w$U}w`+CKJFHP=D?jMiAp-R%W+auo`@ zC!&wD3X~I)UXG+<4QEhJR*5vb0Jaa40CAceZDM|qI2eHw!w;}8Ej8f8wVcJhpMQ+h zLMSs}hhp#Q@dp~}MC~A)iATJyJ(UtQj&eHH2sEH^p9#_ZMJOau zMuT%?peN?RgiChhTD+1f5(QZq@&Si6Nk;7G5(*;}=>-X6Qo%;a7L9aMT(Eg1I*tFp zm5ZrWO7LQ78!koaJghto5#AEJ9e1L~fg{7r_!8otYhQchXb6Oz6;eXKSYU-7? z#Xv5+mJe_k(^Sq0F=R%R*Z{2_b*uy;y6^)ge_4rkAbBm10qA+Z2@K=T$dvdJa6)Tc zfvr^w#fv0yaTH2kUW%-h%%FBrKg=PToN185oY0tAgh*i~ePy}Y2hBiW5-gMnVBzP6 zJaQuOSp^$SsHG;^n1(P%!GAYYdJz#Wk@A-xERvL9Qg~jHBskVGBr5wAZ=o;1o{c$0 z6DQUX>>wP*6A`q*&^4)lDp z5RYn9=hPb3zkd+cOos;W%?0z*=g-iy3zM{8S~5Z(m{RkK(#SNYuYWAiIus#51Ng7+ zWEGB<%nA{oNME0rx{u%I!HoYx4)#y5r|Z)qs%Jc|CZ6xyW*woR z12+Ive8<7yu>EOK&tN|7PkQ#h0`6E|-RRCJw-)x zEB6QAW|qUv%~bJVUXR~B~U;o1Iw!$TPXrDiHH9;kt4^za%zU4WqEbu=dO+a Y0(0XkK_;5xDgXcg07*qoM6N<$f~KHuegFUf delta 1259 zcmV!4fWXc(0E;kIr-d;fVSm-xSrIGGWy<2NjY90H zuHi(?n#{cx;vU6Vmf6KQUT)yZ*X&~vPM%9l$aQ_5E&*RKw3-L_AHayxR5A%9E_ihX zFhEDjb#AXLy^Cyd2g^+jpJ+{MNNy;O>i((qZ-Uy)zTKrMytuQ$QpqHr%{6=}ts3hn z;LA;o6%ut$>wj$CtywgwswOdDaAghDIi!W?`)DT4*|>(V7`U?`zO8l}<9>#j@oMl0|0PCV_ubDG8)#I_KRHEZ?Iq3|G5eRCy5!({z{ti0;ax}qmw)QvdNT0{BUi^d)v1g8U-2slvpMP@~M+FG(K zK7R+d&~YsS#a(1Wbby;I;};5f*nkm@$~Ms71nTIFAHLD7@pS5DTeeuk?m`0y5eqWS zL#w@>8P1zn>W%6{t8KjiC=BIAF@62^uYZd#xl?MVD3iWKji2T`@l{<(v4t)F24tis6^(kzl-u&6+%ri+`*H zWfrLVb}`P$!#JLmDx@1*eZ-5oA;B^!4BR^v`%ZlK9^Wk1oUxE)vaUC=-C>UXVywk~ z*-EjQ(qV`fn$6tmLSEdweb1Q)Goqlne~4vW^`!9FNxWME@-<+L=IvvcIUTS3jhYjZ zVZ|_j2npozKDi(V%&`xvX(PEMWn}dv3HYjz?1`-GuLv;^3N)t|Z^o7UlnX}sN4kk6 zH>!%s_OGGQt1D&(5q^=4xxOneWCn3jLLw3W8962E-?Ak)*OrQUortuteUSdr_!qwl V(5@l=GIam|002ovPDHLkV1g{PXjlLM diff --git a/tools/glb_to_sprite.py b/tools/glb_to_sprite.py new file mode 100644 index 0000000..447fdf4 --- /dev/null +++ b/tools/glb_to_sprite.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""glb_to_sprite.py — turn a generated 3D mesh into a game-ready prop sprite. + + python3 tools/glb_to_sprite.py [--rake 60] [--px 512] + +The missing hop in the [GLB] pipeline of docs/SPACES.md. TRELLIS.2 on +MODELBEAST gives us unlimited meshes from generated art; this renders one from +a FIXED orthographic camera onto transparent film, then hands the PNG to +props_import.py exactly like a flux sprite. + +Why bother, when flux can draw a prop directly: flux picks its own camera every +generation. The 2026-07-22 batch produced a perfectly top-down urinal and a +front-on fridge from the same prompt style, and a tileset of mismatched camera +angles never reads as one room. A mesh renders from the same camera every time. + +RAKE, and why it is not 90: the venue's props are three-quarter top-down, not +straight down. Rendered from directly overhead a bar fridge is a grey +rectangle — physically correct, visually dead, because the glowing bottles that +say "fridge" are on its front. 60 degrees keeps the top surface dominant while +leaving enough face to identify the object at 32px. Match this to the existing +art, not to orthographic purity. + +Runs Blender headless; no GUI, no addon, no human in the loop. +""" +import subprocess +import sys +from pathlib import Path + +BLENDER = "/Applications/Blender.app/Contents/MacOS/Blender" +REPO = Path(__file__).resolve().parent.parent + +# Executed INSIDE Blender's python. Kept as a string so this file stays a +# normal script you can run from the repo root. +BLENDER_SCRIPT = r''' +import bpy, sys, math +from mathutils import Vector + +argv = sys.argv[sys.argv.index("--") + 1:] +glb_path, out_png, rake_deg, px = argv[0], argv[1], float(argv[2]), int(argv[3]) + +# Empty the factory scene: the default cube would render as a prop. +bpy.ops.wm.read_factory_settings(use_empty=True) +bpy.ops.import_scene.gltf(filepath=glb_path) + +meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"] +if not meshes: + raise SystemExit("no mesh in glb") + +# Fit an ortho camera to the whole model's bounding box, so any mesh at any +# scale fills the frame identically — that is the consistency we came for. +lo = Vector((1e9, 1e9, 1e9)) +hi = Vector((-1e9, -1e9, -1e9)) +for o in meshes: + for corner in o.bound_box: + w = o.matrix_world @ Vector(corner) + lo = Vector((min(lo[i], w[i]) for i in range(3))) + hi = Vector((max(hi[i], w[i]) for i in range(3))) +centre = (lo + hi) / 2 +span = max((hi - lo)[i] for i in range(3)) or 1.0 + +cam_data = bpy.data.cameras.new("cam") +cam_data.type = "ORTHO" +cam_data.ortho_scale = span * 1.25 # a little air around the object +cam = bpy.data.objects.new("cam", cam_data) +bpy.context.scene.collection.objects.link(cam) + +rake = math.radians(rake_deg) +dist = span * 3 +cam.location = centre + Vector((0, -math.cos(rake) * dist, math.sin(rake) * dist)) +cam.rotation_euler = (math.pi / 2 - rake, 0, 0) +bpy.context.scene.camera = cam + +# Flat, even light. Props are lit by the GAME (venueMap LIGHTS), so baked-in +# shadows and a hero key light would fight the room's own lighting. +world = bpy.data.worlds.new("w") +world.use_nodes = True +world.node_tree.nodes["Background"].inputs[1].default_value = 1.6 +bpy.context.scene.world = world +for vec in ((0, 0, 1), (1, -1, 0.5), (-1, -1, 0.5)): + ld = bpy.data.lights.new("l", type="SUN") + ld.energy = 2.0 + lo_ = bpy.data.objects.new("l", ld) + lo_.location = centre + Vector(vec) * span * 4 + lo_.rotation_euler = (math.atan2(math.hypot(vec[0], vec[1]), vec[2]), 0, math.atan2(vec[1], vec[0]) + math.pi / 2) + bpy.context.scene.collection.objects.link(lo_) + +sc = bpy.context.scene +# EEVEE's identifier moved between Blender 4.x and 5.x, so ask the property +# what it will accept rather than guessing from the engine registry. +for engine in ("BLENDER_EEVEE_NEXT", "BLENDER_EEVEE", "CYCLES"): + try: + sc.render.engine = engine + break + except TypeError: + continue +sc.render.resolution_x = sc.render.resolution_y = px +sc.render.film_transparent = True # props_import keys off alpha +sc.render.image_settings.file_format = "PNG" +sc.render.image_settings.color_mode = "RGBA" +sc.render.filepath = out_png +bpy.ops.render.render(write_still=True) +print("RENDERED", out_png) +''' + + +def main() -> None: + if len(sys.argv) < 3: + sys.exit(__doc__) + glb, kind = Path(sys.argv[1]).resolve(), sys.argv[2] + rake = sys.argv[sys.argv.index("--rake") + 1] if "--rake" in sys.argv else "60" + px = sys.argv[sys.argv.index("--px") + 1] if "--px" in sys.argv else "512" + if not glb.exists(): + sys.exit(f"no such mesh: {glb}") + if not Path(BLENDER).exists(): + sys.exit(f"Blender not found at {BLENDER}") + + script = REPO / "art_incoming" / "_glb_render.py" + script.parent.mkdir(exist_ok=True) + script.write_text(BLENDER_SCRIPT) + raw = REPO / "art_incoming" / f"{kind}.png" + + proc = subprocess.run( + [BLENDER, "-b", "--factory-startup", "-P", str(script), "--", + str(glb), str(raw), rake, px], + capture_output=True, text=True, timeout=900, + ) + if "RENDERED" not in proc.stdout: + print(proc.stdout[-2000:]) + print(proc.stderr[-2000:], file=sys.stderr) + sys.exit("render failed") + script.unlink(missing_ok=True) + print(f"rendered {raw}") + + # Straight into the existing sprite pipeline — same as any flux raw. + subprocess.run([sys.executable, str(REPO / "tools" / "props_import.py"), str(raw), kind], check=True) + + +if __name__ == "__main__": + main()