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>
390 lines
13 KiB
Python
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()
|