Files
settled-reach/.claude/skills/glb-gen/scripts/postprocess_glb.py
T
jpmschweitzerandClaude Opus 5 201dabd19b refactor(tooling): T-1273 — the Blender carve-out, and a guard that keeps it carved
35 payloads move to tooling/scripts/blender/ and stay outside package scope.
They run under Blender's bundled Python, which cannot see the repo venv, so
they physically cannot import tooling.core — holding them to the D-263 contract
would either fail the gate forever or force the contract to be weakened for
everyone, and the second is how a gate stops meaning anything.

Count verified by import rather than filename: 33 import bpy/bmesh directly,
and the two that do not are still payloads per their own usage lines.
garment-fit/make_logo.py is the one genuine non-payload and stays for T-1290.

The bash wrapper is retired rather than kept. Keeping it would have put the
install-resolution logic in two places, which is the duplication T-1286 had
just finished collapsing three copies of. domains/blender/service.py owns the
decisions — resolve_blender (native beats flatpak, ordering preserved),
resolve_payload, absolutise — and only run_payload performs. test_blender.py
pins all of them without launching Blender, which matters here more than
usual: the thing being launched is a 200 MB GUI application that writes GLBs.

`reach blender run` takes a registered payload name OR a path to any script,
because the wrapper served both — the spikes and the glb-gen skill hand it
one-off scripts of their own. An unknown name enumerates all 35 and exits 2.

The exclusion now defends itself. check_carve_out_stays_carved fails if
`scripts` is added to PACKAGE_ROOTS, if the payload directory empties (an empty
exclusion proves nothing), or if an __init__.py appears there (which would make
the payloads importable — the coupling the carve-out exists to prevent). All
three arms mutation-proved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 20:55:52 +02:00

390 lines
13 KiB
Python

#!/usr/bin/env python3
"""
GLB post-processor for The Settled Reach asset pipeline.
Run via Blender headless:
reach blender run postprocess_glb.py -- input.glb output.glb [options]
Operations:
1. Normalize scale to fit a target bounding box (default 1x1x1 world units)
2. Preserve Trellis texture, bake a recolor mask for the dominant color region
3. Center the model on the origin, feet on the floor (Y=0)
4. Export clean .glb + mask PNG sidecar
The mask texture marks the dominant color region (white = replaceable by engine
tint, black = keep original Trellis detail). Godot loads the mask as a second
texture and uses a color-key shader for runtime recoloring.
Options:
--target-width FLOAT Target width in world units (default: 1.0)
--target-height FLOAT Target height in world units (default: auto from aspect ratio)
--color-threshold FLOAT Distance threshold for dominant color detection (default: 0.25)
--material-name NAME Material slot name (default: mat_primary)
"""
import bpy
import bmesh
import sys
import os
import math
from mathutils import Vector
def get_script_args():
"""Extract arguments after '--' from Blender's sys.argv."""
try:
idx = sys.argv.index("--")
return sys.argv[idx + 1:]
except ValueError:
return []
def parse_args(args):
"""Parse script arguments."""
if len(args) < 2:
print("Usage: postprocess_glb.py -- input.glb output.glb [--target-width N] [--target-height N] [--color-threshold N] [--material-name name]")
sys.exit(1)
result = {
"input": args[0],
"output": args[1],
"target_width": 1.0,
"target_height": None,
"color_threshold": 0.25,
"material_name": "mat_primary",
}
i = 2
while i < len(args):
if args[i] == "--target-width" and i + 1 < len(args):
result["target_width"] = float(args[i + 1])
i += 2
elif args[i] == "--target-height" and i + 1 < len(args):
result["target_height"] = float(args[i + 1])
i += 2
elif args[i] == "--color-threshold" and i + 1 < len(args):
result["color_threshold"] = float(args[i + 1])
i += 2
elif args[i] == "--material-name" and i + 1 < len(args):
result["material_name"] = args[i + 1]
i += 2
else:
print(f"Unknown argument: {args[i]}")
i += 1
return result
def clear_scene():
"""Remove all objects from the scene."""
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete()
# Clear orphan data
for block in bpy.data.meshes:
if block.users == 0:
bpy.data.meshes.remove(block)
for block in bpy.data.materials:
if block.users == 0:
bpy.data.materials.remove(block)
for block in bpy.data.images:
if block.users == 0:
bpy.data.images.remove(block)
def import_glb(filepath):
"""Import a .glb file."""
print(f" Importing {filepath}...")
bpy.ops.import_scene.gltf(filepath=filepath)
return [obj for obj in bpy.context.scene.objects if obj.type == 'MESH']
def get_combined_bbox(objects):
"""Get the combined bounding box of all mesh objects."""
min_co = Vector((float('inf'), float('inf'), float('inf')))
max_co = Vector((float('-inf'), float('-inf'), float('-inf')))
for obj in objects:
for corner in obj.bound_box:
world_co = obj.matrix_world @ Vector(corner)
min_co.x = min(min_co.x, world_co.x)
min_co.y = min(min_co.y, world_co.y)
min_co.z = min(min_co.z, world_co.z)
max_co.x = max(max_co.x, world_co.x)
max_co.y = max(max_co.y, world_co.y)
max_co.z = max(max_co.z, world_co.z)
return min_co, max_co
def normalize_scale(objects, target_width, target_height=None):
"""Scale all objects so the combined bounding box fits the target size."""
min_co, max_co = get_combined_bbox(objects)
size = max_co - min_co
if size.x == 0 and size.y == 0 and size.z == 0:
print(" WARNING: Zero-size bounding box, skipping scale normalization")
return 1.0
# In Blender: X=right, Y=forward, Z=up
# Our game: width is max(X, Y), height is Z
current_width = max(size.x, size.y)
current_height = size.z
if current_width == 0:
current_width = 0.001
# Scale to target width
scale_factor = target_width / current_width
# If target height specified, use the more constraining dimension
if target_height is not None and current_height > 0:
height_scale = target_height / current_height
scale_factor = min(scale_factor, height_scale)
print(f" Current size: {size.x:.3f} x {size.y:.3f} x {size.z:.3f}")
print(f" Scale factor: {scale_factor:.4f}")
print(f" Result size: {size.x * scale_factor:.3f} x {size.y * scale_factor:.3f} x {size.z * scale_factor:.3f}")
# Apply scale to all objects
for obj in objects:
obj.scale *= scale_factor
# Apply transforms
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
return scale_factor
def center_on_origin(objects):
"""Center the combined bounding box on origin, bottom at Z=0."""
min_co, max_co = get_combined_bbox(objects)
center = (min_co + max_co) / 2.0
# Move so center X/Y is at origin, bottom Z is at 0
offset = Vector((-center.x, -center.y, -min_co.z))
for obj in objects:
obj.location += offset
# Apply location
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.transform_apply(location=True, rotation=False, scale=False)
print(f" Centered: offset applied ({offset.x:.3f}, {offset.y:.3f}, {offset.z:.3f})")
def find_texture_image(objects):
"""Find the base color texture image from the imported materials."""
for obj in objects:
if obj.type != 'MESH':
continue
for slot in obj.material_slots:
mat = slot.material
if mat is None or not mat.use_nodes:
continue
for node in mat.node_tree.nodes:
if node.type == 'TEX_IMAGE' and node.image is not None:
return node.image
return None
def analyze_dominant_color(image):
"""Find the dominant color in a Blender image by pixel frequency.
Quantizes to 8-bit buckets (32 levels per channel) and returns the
center of the largest bucket as (r, g, b).
"""
pixels = list(image.pixels) # flat RGBA
width, height = image.size
total = width * height
# Quantize into buckets (5-bit per channel = 32 levels)
LEVELS = 32
buckets = {}
for i in range(0, len(pixels), 4):
r, g, b = pixels[i], pixels[i + 1], pixels[i + 2]
a = pixels[i + 3]
if a < 0.1:
continue # skip transparent
qr = int(r * (LEVELS - 1))
qg = int(g * (LEVELS - 1))
qb = int(b * (LEVELS - 1))
key = (qr, qg, qb)
if key in buckets:
buckets[key][0] += 1
buckets[key][1] += r
buckets[key][2] += g
buckets[key][3] += b
else:
buckets[key] = [1, r, g, b]
if not buckets:
return (0.8, 0.8, 0.8)
# Find the largest bucket
best_key = max(buckets, key=lambda k: buckets[k][0])
count, sum_r, sum_g, sum_b = buckets[best_key]
dominant = (sum_r / count, sum_g / count, sum_b / count)
pct = (count / total) * 100 if total > 0 else 0
print(f" Dominant color: ({dominant[0]:.2f}, {dominant[1]:.2f}, {dominant[2]:.2f}) — {pct:.1f}% of pixels")
return dominant
def generate_mask(image, dominant_color, threshold, output_path):
"""Generate a recolor mask: white where pixels are near the dominant color,
black elsewhere. Saves as a PNG sidecar next to the GLB."""
pixels = list(image.pixels)
width, height = image.size
dr, dg, db = dominant_color
mask_pixels = [0.0] * (width * height * 4)
replaceable_count = 0
total_count = 0
for i in range(0, len(pixels), 4):
px_idx = i // 4
r, g, b, a = pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]
if a < 0.1:
# Transparent — not replaceable
mask_pixels[i + 3] = 0.0
continue
total_count += 1
# Euclidean distance in RGB space
dist = ((r - dr) ** 2 + (g - dg) ** 2 + (b - db) ** 2) ** 0.5
if dist <= threshold:
# Replaceable region — white
mask_pixels[i] = 1.0
mask_pixels[i + 1] = 1.0
mask_pixels[i + 2] = 1.0
mask_pixels[i + 3] = 1.0
replaceable_count += 1
else:
# Detail region — black
mask_pixels[i] = 0.0
mask_pixels[i + 1] = 0.0
mask_pixels[i + 2] = 0.0
mask_pixels[i + 3] = 1.0
pct = (replaceable_count / total_count * 100) if total_count > 0 else 0
print(f" Mask: {replaceable_count}/{total_count} pixels replaceable ({pct:.1f}%)")
# Create a new Blender image for the mask
mask_img = bpy.data.images.new("recolor_mask", width, height, alpha=True)
mask_img.pixels = mask_pixels
mask_img.filepath_raw = output_path
mask_img.file_format = 'PNG'
mask_img.save()
print(f" Mask saved: {output_path}")
return mask_img
def setup_materials(objects, material_name):
"""Keep the original Trellis texture but rename the material slot.
Sets roughness high and specular low for toon compatibility."""
for obj in objects:
if obj.type != 'MESH':
continue
for slot in obj.material_slots:
mat = slot.material
if mat is None:
continue
mat.name = material_name
if not mat.use_nodes:
mat.roughness = 1.0
mat.specular_intensity = 0.0
else:
# Find the Principled BSDF and adjust for toon
for node in mat.node_tree.nodes:
if node.type == 'BSDF_PRINCIPLED':
node.inputs['Roughness'].default_value = 1.0
node.inputs['Specular IOR Level'].default_value = 0.0
print(f" Material renamed to '{material_name}', roughness=1.0, specular=0.0")
def export_glb(filepath):
"""Export the scene as .glb, preserving embedded textures."""
print(f" Exporting to {filepath}...")
os.makedirs(os.path.dirname(os.path.abspath(filepath)), exist_ok=True)
# Ensure all images are packed — Trellis GLBs embed textures, but after
# Blender import they may become external references that point nowhere.
packed = 0
for img in bpy.data.images:
if img.packed_file is None and img.filepath:
try:
img.pack()
packed += 1
except Exception as e:
print(f" WARNING: Could not pack image '{img.name}': {e}")
if packed:
print(f" Packed {packed} image(s) back into the blend data")
bpy.ops.export_scene.gltf(
filepath=filepath,
export_format='GLB',
use_selection=False,
export_apply=True,
export_materials='EXPORT',
export_image_format='AUTO',
)
size = os.path.getsize(filepath)
print(f" Exported: {filepath} ({size} bytes)")
def main():
args = parse_args(get_script_args())
print(f"\n=== GLB Post-Processor ===")
print(f" Input: {args['input']}")
print(f" Output: {args['output']}")
print(f" Target width: {args['target_width']}")
print(f" Color threshold: {args['color_threshold']}")
print(f" Material: {args['material_name']}")
print()
# Clear and import
clear_scene()
mesh_objects = import_glb(args['input'])
if not mesh_objects:
print(" ERROR: No mesh objects found in .glb")
sys.exit(1)
print(f" Found {len(mesh_objects)} mesh object(s)")
# Normalize scale
normalize_scale(mesh_objects, args['target_width'], args['target_height'])
# Center on origin, feet on floor
center_on_origin(mesh_objects)
# Analyze texture and generate recolor mask (if texture exists)
tex_image = find_texture_image(mesh_objects)
if tex_image:
dominant = analyze_dominant_color(tex_image)
mask_path = os.path.splitext(os.path.abspath(args['output']))[0] + "_mask.png"
generate_mask(tex_image, dominant, args['color_threshold'], mask_path)
# Keep original texture, just rename material and adjust PBR
setup_materials(mesh_objects, args['material_name'])
else:
print(" No texture found — Trellis output has no atlas.")
print(" Keeping existing material as-is (flat color, single surface).")
setup_materials(mesh_objects, args['material_name'])
# Export (preserves texture in GLB)
export_glb(args['output'])
print("\n=== Done ===\n")
if __name__ == "__main__":
main()