not-tonight/tools/glb_to_sprite.py
m3ultra 4caa901df2 Four venues, four rooms: the ladder stops being one map with different numbers
The venue ladder has always listed four venues in data/venues.ts, but they
shared ONE floor map and differed only by difficulty knobs — so a promotion you
survived a whole week for looked exactly like the room you had just left.

- venueMap.ts grows a FloorLayout contract (map + props + lights + posts +
  probes + palette) and the newGrid() primitives every room is painted with.
  Voltage stays IN venueMap rather than moving to layouts/, so the new rooms can
  import primitives from it without closing an import cycle.
- Three new rooms in src/scenes/floor/layouts/: The Royal (horseshoe public bar,
  pool room, pokies corner, trough + two cubicles, a beer garden that is plainly
  the nicest room in the pub, and a DJ "corner" that is a folding table because
  this pub never built a booth), Elevate (open-air rooftop — most of the grid is
  sky, the DJ is on an unwalled plinth, you arrive by lift), ROOM (concrete
  warehouse, pillars you path around, central booth, loading-dock smoking area,
  twelve lights in the whole venue).
- Nothing about a room is a module singleton any more. FloorView, sweep and
  FloorDemoScene take the layout; the door picks its street plate by venue id.
- FloorDemoScene's six hardcoded station spots (TAPS_SPOT, DECKS_SPOT, ...) were
  Voltage's tile coordinates. At four venues a hardcoded (47,3) puts the bar
  shift inside The Royal's pool room, so each layout now names its own stations
  and the scene reads them from there.
- tests/floor/layoutInvariants.ts is an executable rulebook: perimeter holds,
  every walkable tile reachable from the entry, anchors on walkable ground,
  posts not sealed (a post on a sealed tile freezes the player for the night —
  that has shipped twice), probes reachable, props on-grid. Proved against
  Voltage BEFORE the new rooms were authored against it.

Dev route #floor:<venueId> boots the floor straight into one room, because
otherwise seeing ROOM means surviving three weeks of the ladder.

Gate: lint clean, build clean, 821 tests passing (was 784).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 19:51:54 +10:00

144 lines
5.8 KiB
Python

#!/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 <mesh.glb> <kind> [--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}")
# Per-kind, not a single shared `_glb_render.py`: the batch generator runs
# several of these at once, and on one shared path a finishing render
# unlinks the script out from under a starting one — which fails as a
# baffling "cannot read script" on a prop that has nothing wrong with it.
script = REPO / "art_incoming" / f"_glb_render_{kind.replace(':', '_')}.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()