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>
372 lines
15 KiB
Python
372 lines
15 KiB
Python
"""
|
||
blender_author_offset_shell_legs.py (T-1089, lower-body offset-shell garments)
|
||
|
||
Companion to blender_author_offset_shell.py (imported as a module — scene
|
||
helpers, offset/solidify, albedo, rasterizer and export are reused, not
|
||
copied). Where the base script is calibrated for TORSO garments (sleeve cut,
|
||
collar/sleeve regions, chest logo UV), this one authors LOWER-BODY garments
|
||
from seg_hips + leg segments with parameterized cuts/regions, so one script
|
||
serves shorts, jeans, joggers, formal pants, swim trunks:
|
||
|
||
--coverage thigh|full thigh: seg_hips + seg_leg_upper_l/r (shorts)
|
||
full: + seg_leg_lower_l/r (jeans/joggers/formal)
|
||
--hem-frac F fraction of the hem bone's length KEPT below its
|
||
head (bone-plane cut). Hem bone = thigh for
|
||
--coverage thigh, calf for full. 0.78 on the thigh
|
||
= casual shorts hem ~9 cm above the knee.
|
||
--waistband-frac F waistband band height as a fraction of
|
||
(waist rim z − thigh head z). The band is
|
||
classified R in the region mask; everything else
|
||
is G. (B/A unused — 2-region garment.)
|
||
--fabric R,G,B flat toon-friendly base tone (luma ~0.6 keeps the
|
||
toon_garment.gdshader luma-recolor faithful).
|
||
--seed N albedo noise seed (deterministic output).
|
||
|
||
Cut/region thresholds derive PER BODY from that body's own landmarks, same
|
||
philosophy as the base script: the hem plane comes from the thigh/calf bone
|
||
(head→tail, identical 65-bone rig on all 11 bodies), the waist rim is the
|
||
natural top boundary of seg_hips (its own mesh z-max), so proportions match
|
||
across bodies. The waistline itself comes free as a segment boundary — no
|
||
waist join geometry needed.
|
||
|
||
Regions (RGBA mask, toon_garment.gdshader): waistband → R (tint_0),
|
||
body → G (tint_1). A parked logo_uv TEXCOORD_1 layer is authored for channel
|
||
consistency with torso garments (all UVs outside [0,1] → shader draws nothing).
|
||
|
||
Modes mirror the base script:
|
||
|
||
PER-BODY (preferred for offset shells, Q-060):
|
||
reach blender run blender_author_offset_shell_legs \
|
||
<bodies_dir> <out_dir> --per-body \
|
||
[--bodies a,b,c] [--offset 0.012] [--hem-frac 0.78] [--waistband-frac 0.35]
|
||
|
||
Writes <out_dir>/<body>.glb + <out_dir>/<body>_mask.png per body, plus
|
||
shared base_albedo.png and reference_mask.png (= average_m's mask, runtime
|
||
fallback for the compositor).
|
||
|
||
SINGLE-REFERENCE:
|
||
reach blender run blender_author_offset_shell_legs \
|
||
<bodies_dir>/average_m <out_dir> [--offset 0.020] [...]
|
||
|
||
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house wardrobe).
|
||
"""
|
||
|
||
import sys
|
||
import os
|
||
import shutil
|
||
import importlib.util
|
||
|
||
import bpy # noqa: F401 (Blender runtime)
|
||
import bmesh
|
||
import numpy as np
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Import the base authoring module (shared helpers; main() is __main__-guarded)
|
||
# --------------------------------------------------------------------------
|
||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||
_spec = importlib.util.spec_from_file_location(
|
||
"offset_shell_base", os.path.join(_HERE, "blender_author_offset_shell.py"))
|
||
base = importlib.util.module_from_spec(_spec)
|
||
_spec.loader.exec_module(base)
|
||
|
||
log = base.log
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Parameters (defaults = shorts_modern)
|
||
# --------------------------------------------------------------------------
|
||
|
||
COVERAGE_SEGMENTS = {
|
||
"thigh": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r"],
|
||
"full": ["seg_hips", "seg_leg_upper_l", "seg_leg_upper_r",
|
||
"seg_leg_lower_l", "seg_leg_lower_r"],
|
||
}
|
||
HEM_BONES = {"thigh": ("thigh_l", "thigh_r"), "full": ("calf_l", "calf_r")}
|
||
|
||
HEM_FRAC = 0.78 # keep 78% of the thigh → hem above the knee
|
||
WAISTBAND_FRAC = 0.35 # top 35% of waist-rim→thigh-head span = waistband (R)
|
||
FABRIC_RGB = (0.63, 0.60, 0.53) # warm stone khaki, luma ~0.60 (toon-friendly)
|
||
ALBEDO_SEED = 20890 # deterministic albedo noise, distinct from the tshirt
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Per-body threshold derivation
|
||
# --------------------------------------------------------------------------
|
||
|
||
def derive_leg_thresholds(armature, coverage, hem_frac):
|
||
"""Hem plane from this body's own hem bone; thigh head z for the waistband
|
||
span. All 11 bodies share the 65-bone rig, so the landmarks always exist."""
|
||
bones = armature.data.bones
|
||
hem_l, hem_r = HEM_BONES[coverage]
|
||
zs = []
|
||
thigh_heads = []
|
||
for name in (hem_l, hem_r):
|
||
b = bones.get(name)
|
||
if b is None:
|
||
raise RuntimeError(f"hem bone {name} missing on armature")
|
||
zs.append(b.head_local.z + hem_frac * (b.tail_local.z - b.head_local.z))
|
||
for name in ("thigh_l", "thigh_r"):
|
||
b = bones.get(name)
|
||
if b is None:
|
||
raise RuntimeError(f"landmark bone {name} missing on armature")
|
||
thigh_heads.append(b.head_local.z)
|
||
thr = {
|
||
"hem_z": sum(zs) / len(zs),
|
||
"thigh_head_z": sum(thigh_heads) / len(thigh_heads),
|
||
}
|
||
log(f"thresholds: hem z>={thr['hem_z']:.3f} "
|
||
f"(hem bone {hem_l}/{hem_r}, keep {hem_frac:.2f}) "
|
||
f"thigh head z={thr['thigh_head_z']:.3f}")
|
||
return thr
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Seam weld — the body segment meshes are assembled from patches whose seam
|
||
# vertices are coincident but DUPLICATED. Each copy's normal averages only its
|
||
# own patch's faces, so the outward offset pulls seams apart (visible slit at
|
||
# the front-centre of seg_hips, V-notches on the waist rim). Welding before
|
||
# the offset merges the copies (weights/UVs are identical on coincident verts)
|
||
# and gives one smooth normal per seam vertex.
|
||
# --------------------------------------------------------------------------
|
||
|
||
def weld_seams(shell, epsilon=1e-4):
|
||
bm = bmesh.new()
|
||
bm.from_mesh(shell.data)
|
||
before = len(bm.verts)
|
||
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=epsilon)
|
||
bm.to_mesh(shell.data)
|
||
bm.free()
|
||
shell.data.update()
|
||
after = len(shell.data.vertices)
|
||
log(f"seam weld merged {before - after} duplicate verts; {after} remain")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Bone-plane hem cut (z threshold — legs hang along -Z in rest pose)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def hem_cut(shell, hem_z):
|
||
"""Delete every vert below the hem plane. A single global z threshold is
|
||
safe: the hips segment bottoms out far above any sensible hem."""
|
||
bm = bmesh.new()
|
||
bm.from_mesh(shell.data)
|
||
bm.verts.ensure_lookup_table()
|
||
to_delete = [v for v in bm.verts if v.co.z < hem_z]
|
||
bmesh.ops.delete(bm, geom=to_delete, context='VERTS')
|
||
bm.to_mesh(shell.data)
|
||
bm.free()
|
||
shell.data.update()
|
||
log(f"hem cut removed {len(to_delete)} verts below z={hem_z:.3f}; "
|
||
f"{len(shell.data.vertices)} remain")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Region mask: waistband (R) / body (G)
|
||
# --------------------------------------------------------------------------
|
||
|
||
def bake_region_mask_legs(shell, out_path, thr, waistband_frac):
|
||
"""Rasterize UV0 faces: waistband → R, everything else → G.
|
||
|
||
MUST run PRE-solidify (open boundaries still present). The waistband is
|
||
boundary-anchored: the waist rim is the top open boundary loop of seg_hips
|
||
(the rim DIPS ~5 cm at the navel, so a global z-max test misses the front
|
||
row), so a face is waistband when it touches a rim-boundary vertex — that
|
||
keys the full top face row on every tessellation — or when its centre lies
|
||
inside the proportional band. Solidify afterwards duplicates the UV loops
|
||
unchanged, so the baked texels serve outer shell, inner shell and rim caps
|
||
alike."""
|
||
W = H = base.MASK_SIZE
|
||
buf = np.zeros((H, W, 4), dtype=np.float32)
|
||
buf[:, :, 1] = 1.0 # green background = body (bilinear-bleed safe)
|
||
|
||
me = shell.data
|
||
bm = bmesh.new()
|
||
bm.from_mesh(me)
|
||
bm.faces.ensure_lookup_table()
|
||
bm.verts.ensure_lookup_table()
|
||
uv_layer = bm.loops.layers.uv.active
|
||
if uv_layer is None:
|
||
raise RuntimeError("no active UV layer for region mask bake")
|
||
|
||
waist_rim_z = max(v.co.z for v in bm.verts)
|
||
band = waistband_frac * (waist_rim_z - thr["thigh_head_z"])
|
||
wb_z_min = waist_rim_z - band
|
||
|
||
# Waist-rim boundary verts: on an open boundary AND above the thigh head
|
||
# (the only other boundaries are the leg hems, far below).
|
||
rim_verts = set()
|
||
for e in bm.edges:
|
||
if e.is_boundary:
|
||
for v in e.verts:
|
||
if v.co.z >= thr["thigh_head_z"]:
|
||
rim_verts.add(v.index)
|
||
log(f"waistband: rim z={waist_rim_z:.3f} band {band*100:.1f} cm "
|
||
f"→ R for z>={wb_z_min:.3f} or touching {len(rim_verts)} rim verts")
|
||
|
||
counts = {"waistband": 0, "body": 0}
|
||
for face in bm.faces:
|
||
in_band = face.calc_center_median().z >= wb_z_min
|
||
touches_rim = any(v.index in rim_verts for v in face.verts)
|
||
if in_band or touches_rim:
|
||
color = (1.0, 0.0, 0.0, 0.0)
|
||
counts["waistband"] += 1
|
||
else:
|
||
color = (0.0, 1.0, 0.0, 0.0)
|
||
counts["body"] += 1
|
||
for a, b, c in base._tris_from_face(face, uv_layer):
|
||
base._raster_tri(buf, a, b, c, color, W, H)
|
||
bm.free()
|
||
total = max(sum(counts.values()), 1)
|
||
log("region faces: " + " ".join(
|
||
f"{k}={v} ({100.0 * v / total:.1f}%)" for k, v in counts.items()))
|
||
|
||
img = bpy.data.images.new("garment_region_mask", W, H, alpha=True)
|
||
img.pixels.foreach_set(buf.reshape(-1))
|
||
img.update()
|
||
img.filepath_raw = out_path
|
||
img.file_format = 'PNG'
|
||
img.save()
|
||
log(f"baked region mask -> {out_path}")
|
||
|
||
|
||
def park_logo_uv(shell):
|
||
"""Author a TEXCOORD_1 layer with every loop parked outside [0,1] — keeps
|
||
the UV-channel layout identical to torso garments; the shader's in-box
|
||
guard means nothing ever draws."""
|
||
me = shell.data
|
||
while len(me.uv_layers) > 1:
|
||
me.uv_layers.remove(me.uv_layers[-1])
|
||
me.uv_layers.new(name="logo_uv")
|
||
me.uv_layers.active = me.uv_layers[0]
|
||
bm = bmesh.new()
|
||
bm.from_mesh(me)
|
||
uvl = bm.loops.layers.uv.get("logo_uv")
|
||
for face in bm.faces:
|
||
for loop in face.loops:
|
||
loop[uvl].uv = (2.0, 2.0)
|
||
bm.to_mesh(me)
|
||
bm.free()
|
||
me.update()
|
||
log("logo UV2 parked (no logo region on this garment)")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Author one body
|
||
# --------------------------------------------------------------------------
|
||
|
||
def author_legs_shell(body_dir, out_dir, glb_name, mask_name, offset,
|
||
coverage, hem_frac, waistband_frac, seed):
|
||
base.clear_scene()
|
||
base.COVERED_SEGMENTS = COVERAGE_SEGMENTS[coverage]
|
||
shell, armature = base.build_covered_mesh(body_dir)
|
||
weld_seams(shell)
|
||
thr = derive_leg_thresholds(armature, coverage, hem_frac)
|
||
hem_cut(shell, thr["hem_z"])
|
||
base.offset_outward(shell, offset)
|
||
|
||
# Region mask + parked logo UV are authored PRE-solidify: the boundary-
|
||
# anchored waistband needs the open waist rim, and solidify duplicates all
|
||
# UV loops unchanged so the baked texels stay valid for the final mesh.
|
||
park_logo_uv(shell)
|
||
bake_region_mask_legs(shell, os.path.join(out_dir, mask_name), thr,
|
||
waistband_frac)
|
||
|
||
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
||
|
||
base.FABRIC_RGB = FABRIC_RGB
|
||
albedo_img = base.make_base_albedo_image(seed=seed)
|
||
base.assign_fabric_material(shell, albedo_img)
|
||
base.save_albedo_sidecar(albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
||
|
||
base.export_reference(shell, armature, os.path.join(out_dir, glb_name))
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# Entry
|
||
# --------------------------------------------------------------------------
|
||
|
||
def main():
|
||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||
if len(argv) < 2:
|
||
print("Usage: -- <bodies_dir>/average_m <out_dir> [--offset M] "
|
||
"[--coverage thigh|full] [--hem-frac F] [--waistband-frac F] "
|
||
"[--fabric R,G,B] [--seed N]\n"
|
||
" or: -- <bodies_dir> <out_dir> --per-body [--bodies a,b,c] "
|
||
"[same flags]")
|
||
sys.exit(1)
|
||
in_dir = argv[0]
|
||
out_dir = argv[1]
|
||
per_body = "--per-body" in argv
|
||
|
||
global FABRIC_RGB
|
||
offset = None
|
||
coverage = "thigh"
|
||
hem_frac = HEM_FRAC
|
||
waistband_frac = WAISTBAND_FRAC
|
||
seed = ALBEDO_SEED
|
||
if "--offset" in argv:
|
||
offset = float(argv[argv.index("--offset") + 1])
|
||
if "--coverage" in argv:
|
||
coverage = argv[argv.index("--coverage") + 1]
|
||
if coverage not in COVERAGE_SEGMENTS:
|
||
print(f"unknown --coverage {coverage}")
|
||
sys.exit(1)
|
||
if "--hem-frac" in argv:
|
||
hem_frac = float(argv[argv.index("--hem-frac") + 1])
|
||
if "--waistband-frac" in argv:
|
||
waistband_frac = float(argv[argv.index("--waistband-frac") + 1])
|
||
if "--fabric" in argv:
|
||
FABRIC_RGB = tuple(
|
||
float(c) for c in argv[argv.index("--fabric") + 1].split(","))
|
||
if "--seed" in argv:
|
||
seed = int(argv[argv.index("--seed") + 1])
|
||
bodies = base.BODY_TYPES
|
||
if "--bodies" in argv:
|
||
bodies = [s.strip() for s in argv[argv.index("--bodies") + 1].split(",")]
|
||
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
|
||
if not per_body:
|
||
offset = base.OFFSET_M if offset is None else offset
|
||
body = os.path.basename(os.path.normpath(in_dir))
|
||
author_legs_shell(in_dir, out_dir, f"{body}.glb", "reference_mask.png",
|
||
offset, coverage, hem_frac, waistband_frac, seed)
|
||
log("DONE")
|
||
return
|
||
|
||
offset = base.PER_BODY_OFFSET_M if offset is None else offset
|
||
log(f"per-body mode: {len(bodies)} bodies, offset {offset*1000:.0f} mm, "
|
||
f"coverage={coverage} hem_frac={hem_frac} waistband_frac={waistband_frac}")
|
||
results = []
|
||
for body in bodies:
|
||
body_dir = os.path.join(in_dir, body)
|
||
log(f"=== {body} ===")
|
||
if not os.path.isdir(body_dir):
|
||
results.append((body, "skipped: body dir missing"))
|
||
continue
|
||
try:
|
||
author_legs_shell(body_dir, out_dir, f"{body}.glb",
|
||
f"{body}_mask.png", offset, coverage, hem_frac,
|
||
waistband_frac, seed)
|
||
results.append((body, "ok"))
|
||
except Exception as exc:
|
||
log(f"ERROR {body}: {exc}")
|
||
results.append((body, f"error: {exc}"))
|
||
|
||
ref_mask = os.path.join(out_dir, f"{base.REFERENCE_BODY}_mask.png")
|
||
if os.path.isfile(ref_mask):
|
||
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
||
log(f"copied {base.REFERENCE_BODY}_mask.png -> reference_mask.png (fallback)")
|
||
|
||
log("=" * 50)
|
||
for body, status in results:
|
||
log(f" {body:12s} {status}")
|
||
ok = sum(1 for _, s in results if s == "ok")
|
||
log(f"OK={ok}/{len(results)}")
|
||
if ok != len(results):
|
||
sys.exit(1)
|
||
log("DONE")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|