A script scanned every tracked doc, rule, skill, agent, hook and source file for tooling/ paths that no longer exist, skipping historical records (sprints, discussions, workshops, governance, generated wiki pages). It found 62. The ones that tell a reader what to RUN now name the reach verb: - The atlas skill still sent agents to tooling/atlas, atlas-verify, atlas-update-field and atlas-commit-and-sync — about forty lines, all retired in T-1285. They now name the `reach atlas` verbs, and the skill records that commit-and-sync STAGES by default (--commit to commit) and takes --corridor as an option. - The clerk agent named tooling/clerk-review (now `reach dev clerk`). The Si and clerk briefings sent those agents to the retired tooling/db/decision and sqlite-query CLIs and to decisions/*.md paths that moved to governance/ in the pql migration. They now name pql. - The ticket-cli rule documented `pql decisions read`, which does not exist; `show` already includes the body. - The culture authoring guide and the RON sources name `reach validate ron`, with the same arguments as before. - The 41 Blender payloads' usage lines ran the retired tooling/blender wrapper, and the docstrings still cited pre-carve-out paths. They now read `reach blender run <payload>`. - Doc comments in server/, client/, wiki TOMLs and the domain modules. What is left is deliberate: "Formerly …" provenance, dated plans and findings docs, the retired-pipeline doc, and a build-artefact path. project.yaml 0.4.14 (mirrored to the client). Comment-only, but four touched files are in the canvas-version registry (trait_catalog_reader.rs, since T-1289, canvas_sources.py itself, and two client files). The gate is path-based and has no override. The previous push was rejected on exactly this. Three of the edits are stamped ledger sources, so systems.db is regenerated and the stamp is fresh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
292 lines
9.9 KiB
Python
292 lines
9.9 KiB
Python
"""
|
|
blender_create_clothing_refs.py
|
|
|
|
Usage:
|
|
reach blender run blender_create_clothing_refs \\
|
|
<bodies_dir> <clothing_output_dir>
|
|
|
|
Creates v0.2 placeholder reference clothing meshes for all 5 initial clothing items.
|
|
Meshes are authored on average_m by importing the relevant body segments, joining
|
|
them, and offsetting vertices outward along normals to simulate clothing thickness.
|
|
|
|
This produces placeholder-quality geometry only — not final art. The geometry reads
|
|
correctly at gameplay zoom and is sufficient to validate the Surface Deform pipeline
|
|
and compositor integration.
|
|
|
|
Arguments:
|
|
bodies_dir Directory with one subdir per body type (e.g. client/assets/characters/bodies/)
|
|
clothing_output_dir Root directory for clothing output (e.g. client/assets/characters/clothing/)
|
|
|
|
Output per item in <clothing_output_dir>/<item_id>/:
|
|
reference.glb -- placeholder clothing mesh on average_m geometry
|
|
|
|
Items produced:
|
|
coveralls_basic -- full-body work suit
|
|
jacket_utility -- upper body outerwear
|
|
pants_cargo -- lower body
|
|
shirt_henley -- upper body inner
|
|
boots_work -- foot slot
|
|
|
|
Decisions: D-162 (clothing pre-baked per body type via Surface Deform)
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import bpy
|
|
import bmesh
|
|
|
|
REFERENCE_BODY = "average_m"
|
|
|
|
# Each item defines:
|
|
# segments -- body segment GLBs (from average_m) to import and join
|
|
# thickness -- outward vertex offset in metres (clothing thickness simulation)
|
|
CLOTHING_ITEMS = {
|
|
"coveralls_basic": {
|
|
"description": "Full-body work suit",
|
|
"segments": [
|
|
"seg_torso", "seg_torso_upper",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
"seg_arm_lower_l", "seg_arm_lower_r",
|
|
"seg_hand_l", "seg_hand_r",
|
|
"seg_leg_upper_l", "seg_leg_upper_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r",
|
|
"seg_foot_l", "seg_foot_r",
|
|
],
|
|
"thickness": 0.006,
|
|
},
|
|
"jacket_utility": {
|
|
"description": "Upper body outerwear",
|
|
"segments": [
|
|
"seg_torso", "seg_torso_upper",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
"seg_arm_lower_l", "seg_arm_lower_r",
|
|
],
|
|
"thickness": 0.008, # slightly thicker for outerwear
|
|
},
|
|
"pants_cargo": {
|
|
"description": "Lower body cargo trousers",
|
|
"segments": [
|
|
"seg_leg_upper_l", "seg_leg_upper_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r",
|
|
],
|
|
"thickness": 0.006,
|
|
},
|
|
"shirt_henley": {
|
|
"description": "Upper body inner shirt",
|
|
"segments": [
|
|
"seg_torso", "seg_torso_upper",
|
|
"seg_arm_upper_l", "seg_arm_upper_r",
|
|
],
|
|
"thickness": 0.004, # thinner for inner layer
|
|
},
|
|
"boots_work": {
|
|
"description": "Work boots (foot slot)",
|
|
"segments": [
|
|
"seg_foot_l", "seg_foot_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r", # boot shaft reaches up the lower leg
|
|
],
|
|
"thickness": 0.010, # thicker for boots
|
|
},
|
|
}
|
|
|
|
|
|
def clear_scene():
|
|
"""Remove all objects and purge orphan data."""
|
|
bpy.ops.object.select_all(action='SELECT')
|
|
bpy.ops.object.delete()
|
|
bpy.ops.outliner.orphans_purge(do_recursive=True)
|
|
|
|
|
|
def import_glb(path):
|
|
"""Import a GLB file. Returns newly added objects."""
|
|
before = set(bpy.context.scene.objects)
|
|
bpy.ops.import_scene.gltf(filepath=path)
|
|
return [o for o in bpy.context.scene.objects if o not in before]
|
|
|
|
|
|
def offset_vertices_along_normals(obj, thickness):
|
|
"""
|
|
Move each vertex outward along its computed normal by `thickness` metres.
|
|
Operates directly on mesh data -- no operators, reliable in headless mode.
|
|
"""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(obj.data)
|
|
bm.verts.ensure_lookup_table()
|
|
|
|
# Ensure normals are up to date
|
|
bm.normal_update()
|
|
|
|
for v in bm.verts:
|
|
v.co += v.normal * thickness
|
|
|
|
bm.to_mesh(obj.data)
|
|
bm.free()
|
|
obj.data.update()
|
|
|
|
|
|
def export_glb(obj, output_path, armature=None):
|
|
"""Export a mesh (and optionally its armature) as GLB with skinning data."""
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
obj.select_set(True)
|
|
if armature:
|
|
armature.select_set(True)
|
|
bpy.context.view_layer.objects.active = obj
|
|
|
|
bpy.ops.export_scene.gltf(
|
|
filepath=output_path,
|
|
use_selection=True,
|
|
export_format='GLB',
|
|
export_animations=False,
|
|
export_yup=True,
|
|
export_image_format='AUTO',
|
|
export_texcoords=True,
|
|
export_normals=True,
|
|
export_skins=armature is not None,
|
|
export_materials='EXPORT',
|
|
)
|
|
|
|
|
|
def create_reference_mesh(item_id, config, bodies_dir, output_dir):
|
|
"""
|
|
Create the reference.glb for one clothing item on average_m geometry.
|
|
Preserves bone weights from body segments so clothing deforms with the skeleton.
|
|
Returns True on success, False on error.
|
|
"""
|
|
print(f"\n{'='*60}")
|
|
print(f" Item: {item_id} — {config['description']}")
|
|
|
|
clear_scene()
|
|
|
|
average_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
|
|
imported_meshes = []
|
|
imported_armatures = []
|
|
missing_segments = []
|
|
|
|
for seg_name in config["segments"]:
|
|
seg_path = os.path.join(average_m_dir, f"{seg_name}.glb")
|
|
if not os.path.isfile(seg_path):
|
|
missing_segments.append(seg_name)
|
|
continue
|
|
objs = import_glb(seg_path)
|
|
meshes = [o for o in objs if o.type == 'MESH']
|
|
armatures = [o for o in objs if o.type == 'ARMATURE']
|
|
imported_meshes.extend(meshes)
|
|
imported_armatures.extend(armatures)
|
|
|
|
if missing_segments:
|
|
print(f" NOTE: Missing segments (skipped): {', '.join(missing_segments)}")
|
|
|
|
if not imported_meshes:
|
|
print(f" ERROR: No segment meshes could be imported for {item_id}")
|
|
return False
|
|
|
|
print(f" Imported {len(imported_meshes)} segments, "
|
|
f"{len(imported_armatures)} armatures "
|
|
f"({len(missing_segments)} missing)")
|
|
|
|
# Use the first armature as the canonical one — all segments share the same rig
|
|
canonical_armature = imported_armatures[0] if imported_armatures else None
|
|
|
|
# Re-parent all meshes to the canonical armature (preserving bone weights)
|
|
if canonical_armature:
|
|
for m in imported_meshes:
|
|
# Clear any existing parent
|
|
m.parent = None
|
|
m.matrix_world = m.matrix_world # preserve world transform
|
|
# Set parent to canonical armature with Armature modifier
|
|
m.parent = canonical_armature
|
|
m.parent_type = 'OBJECT'
|
|
# Ensure Armature modifier exists pointing to canonical armature
|
|
has_armature_mod = False
|
|
for mod in m.modifiers:
|
|
if mod.type == 'ARMATURE':
|
|
mod.object = canonical_armature
|
|
has_armature_mod = True
|
|
if not has_armature_mod:
|
|
mod = m.modifiers.new(name="Armature", type='ARMATURE')
|
|
mod.object = canonical_armature
|
|
|
|
# Remove duplicate armatures (keep only canonical)
|
|
for arm in imported_armatures[1:]:
|
|
bpy.data.objects.remove(arm, do_unlink=True)
|
|
|
|
# Join all segments into one mesh (vertex groups / bone weights are preserved by join)
|
|
bpy.ops.object.select_all(action='DESELECT')
|
|
for m in imported_meshes:
|
|
if m.name in bpy.data.objects:
|
|
m.select_set(True)
|
|
bpy.context.view_layer.objects.active = imported_meshes[0]
|
|
if len(imported_meshes) > 1:
|
|
bpy.ops.object.join()
|
|
|
|
clothing_obj = bpy.context.active_object
|
|
clothing_obj.name = f"ref_{item_id}"
|
|
|
|
vertex_count_before = len(clothing_obj.data.vertices)
|
|
print(f" Mesh vertices: {vertex_count_before}")
|
|
print(f" Vertex groups (bone weights): {len(clothing_obj.vertex_groups)}")
|
|
|
|
# Offset vertices outward to simulate clothing thickness
|
|
thickness = config["thickness"]
|
|
offset_vertices_along_normals(clothing_obj, thickness)
|
|
print(f" Applied {thickness*1000:.1f}mm outward offset")
|
|
|
|
# Export with armature so clothing is skinned
|
|
os.makedirs(output_dir, exist_ok=True)
|
|
output_path = os.path.join(output_dir, "reference.glb")
|
|
export_glb(clothing_obj, output_path, armature=canonical_armature)
|
|
print(f" Exported: {output_path}")
|
|
|
|
return True
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Entry point
|
|
# -------------------------------------------------------------------------
|
|
|
|
if __name__ == "__main__":
|
|
argv = sys.argv
|
|
if "--" not in argv:
|
|
print("Usage: reach blender run blender_create_clothing_refs "
|
|
"<bodies_dir> <clothing_output_dir>")
|
|
sys.exit(1)
|
|
|
|
args = argv[argv.index("--") + 1:]
|
|
if len(args) < 2:
|
|
print("ERROR: Provide <bodies_dir> and <clothing_output_dir>")
|
|
sys.exit(1)
|
|
|
|
bodies_dir = args[0]
|
|
clothing_output_dir = args[1]
|
|
|
|
# Preflight: check average_m dir exists
|
|
avg_m_dir = os.path.join(bodies_dir, REFERENCE_BODY)
|
|
if not os.path.isdir(avg_m_dir):
|
|
print(f"ERROR: Reference body directory not found: {avg_m_dir}")
|
|
sys.exit(1)
|
|
|
|
print("\nClothing Reference Mesh Creator (v0.2 placeholder)")
|
|
print(f" Bodies dir: {bodies_dir}")
|
|
print(f" Output dir: {clothing_output_dir}")
|
|
print(f" Reference: {REFERENCE_BODY}")
|
|
print(f" Items: {len(CLOTHING_ITEMS)}")
|
|
|
|
results = {}
|
|
for item_id, config in CLOTHING_ITEMS.items():
|
|
item_output_dir = os.path.join(clothing_output_dir, item_id)
|
|
success = create_reference_mesh(item_id, config, bodies_dir, item_output_dir)
|
|
results[item_id] = success
|
|
|
|
# Summary
|
|
print(f"\n{'='*60}")
|
|
print("=== Clothing reference creation complete ===")
|
|
ok_items = [k for k, v in results.items() if v]
|
|
fail_items = [k for k, v in results.items() if not v]
|
|
for item_id in CLOTHING_ITEMS:
|
|
status = "OK" if results[item_id] else "FAILED"
|
|
print(f" {item_id:20s} {status}")
|
|
print(f"\n OK: {len(ok_items)} FAILED: {len(fail_items)}")
|
|
|
|
if fail_items:
|
|
sys.exit(1)
|