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 1f07167..5be91a5 100644 Binary files a/public/props/poolTable.png and b/public/props/poolTable.png differ 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()