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>
615 lines
26 KiB
Python
615 lines
26 KiB
Python
"""
|
|
blender_author_boots.py (T-1089 wave 2, boots_modern + ankle-boot family)
|
|
|
|
Authors ANKLE BOOTS as per-body offset shells: both feet PLUS the lower calf
|
|
(seg_leg_lower cut to a boot shaft just above the ankle), with a chunky sole
|
|
extension and painted eyelets/laces up the shaft. Reuses
|
|
blender_author_offset_shell.py (via blender_author_denim_pants.py, imported as
|
|
a module) for the shared machinery — scene build, join, offset, solidify,
|
|
albedo material, GLB export — plus the two denim REQUIRED PRACTICES for
|
|
bottoms/footwear:
|
|
|
|
* boundary WELD of coincident segment-seam rings (denim.weld_boundaries):
|
|
the ankle joins (foot<->leg_lower) duplicate a coincident vert band per
|
|
segment; offsetting un-welded rings along diverging normals opens cracks.
|
|
* open-rim FLATTENING: the shaft cut leaves a jagged "teeth" ring on each
|
|
calf; both rims are pulled down onto one clean shared plane (the deeper
|
|
valley of the two, so the boots match) before offsetting.
|
|
|
|
What this companion adds, as reusable parameters (not hacks):
|
|
|
|
* --shaft-frac: boot shaft height as a fraction of the ankle->knee calf
|
|
span (0.35 cut => rim lands on the lower calf after rim flattening;
|
|
larger values give combat/riding variants).
|
|
* TOE-BOX MERGE: the skin mesh has individual toes; a boot must not. The
|
|
foot region is Laplacian-smoothed pre-offset (mild on the whole foot,
|
|
aggressive forward of the ball joint) so the toes fuse into one rounded
|
|
leather toe box and anatomical detail (ankle knobs, heel tendon) reads
|
|
as boot, not foot. Weights/UVs ride along untouched.
|
|
* SKIN CONTAINMENT CLAMP: smoothing can pull the shell inside the skin it
|
|
no longer follows (melted toe box vs real toes). A BVH of the ORIGINAL
|
|
welded skin is kept, and after the offset every foot-region vert whose
|
|
signed distance to the skin is below a minimum clearance is pushed back
|
|
out along the skin normal — standoff guaranteed by construction, which
|
|
is what the chromakey QA gates on (the runtime does not hide segments).
|
|
* FOOT WEIGHT RE-BIND: smoothing + clamping RELOCATE shell verts but leave
|
|
them carrying their ORIGIN vertex's bone weights, so material that ends
|
|
up hovering over toe N flexes with the bone of toe M — under ball-joint
|
|
flexion (Walk push-off, crouch) the shell diverges from the skin beneath
|
|
it and the toes poke through (QA/preview evidence, wave 2 run 1/2).
|
|
After shaping, every foot-region vert re-copies its vertex-group weights
|
|
from the nearest vert of the original welded skin, so the boot flexes
|
|
exactly with the anatomy each patch of leather actually covers.
|
|
* SOLE geometry as an offset-shell param extension (--sole-drop): the
|
|
under-foot surface created by the outward offset is flattened onto a slab
|
|
plane sole_drop below the skin sole (solidify adds its thickness back,
|
|
so the shipped slab bottom sits exactly sole_drop under the skin), and a
|
|
feathered radial LIP (~5 mm) bulges the sole band outward for the chunky
|
|
work-boot silhouette.
|
|
* --shaft-flare: extra feathered radial stand-off toward the shaft rim.
|
|
Serves the same crouch-fold purpose as the denim waist flare AND buys
|
|
clearance so full-length pant hems (jeans/formal, 12 mm standoff) tuck
|
|
INSIDE the boot shaft instead of z-fighting with it.
|
|
* TEXEL-level feature painting (denim technique, boot field): one analytic
|
|
field evaluation drives BOTH the painted albedo and the region mask so
|
|
they always agree — lace bars + eyelet dots up the front of the shaft,
|
|
welt stitching above the sole, padded collar shading at the rim.
|
|
Regions (spec): sole -> R, upper + shaft -> G, laces + eyelets -> B.
|
|
* a parked logo_uv TEXCOORD_1 layer (boots are not logo-capable, but
|
|
toon_garment.gdshader samples UV2 unconditionally).
|
|
|
|
All cut/mask/paint parameters derive PER BODY from that body's own calf bone
|
|
landmarks and measured skin-sole plane, scaled by the calf-span ratio against
|
|
average_m (clamped so the child keeps believable, not clown, proportions) —
|
|
the same proportional-ratio philosophy as base.derive_thresholds. Per-body
|
|
mode only (offset shells author per body, Q-060).
|
|
|
|
Style pin (T-1089): modern only; identity carried by the texture. The albedo
|
|
is painted at mid luma so toon_garment.gdshader's luma-preserving recolor
|
|
stays faithful; the brown/black work-street default lives in the manifest
|
|
default_tints (sole near-black, upper work brown, laces dark).
|
|
|
|
Usage (boots_modern reference invocation):
|
|
reach blender run blender_author_boots \
|
|
client/assets/characters/bodies \
|
|
client/assets/characters/clothing/boots_modern \
|
|
[--bodies average_m,child,...] [--offset 0.013] [--shaft-frac 0.30] \
|
|
[--shaft-flare 0.008] [--sole-drop 0.020] [--base-rgb r,g,b] [--plain]
|
|
|
|
Writes per body: <out_dir>/<body>.glb (skinned, albedo embedded)
|
|
<out_dir>/<body>_mask.png (RGBA region mask, UV0)
|
|
<out_dir>/<body>_base_albedo.png
|
|
Plus: <out_dir>/base_albedo.png (average_m's, shared sidecar)
|
|
<out_dir>/reference_mask.png (average_m's, runtime fallback)
|
|
|
|
Decisions: D-162 (clothing pre-fitted per body type), D-251 (in-house
|
|
wardrobe), Q-060 (per-body offset shells).
|
|
"""
|
|
|
|
import importlib.util
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import bmesh
|
|
import bpy
|
|
import numpy as np
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Import the denim companion as a library (which itself imports the base
|
|
# offset-shell module). Boot reuse: base machinery + denim's weld/parked-UV2.
|
|
# --------------------------------------------------------------------------
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
_spec = importlib.util.spec_from_file_location(
|
|
"denim_pants_lib", os.path.join(_HERE, "blender_author_denim_pants.py"))
|
|
denim = importlib.util.module_from_spec(_spec)
|
|
_spec.loader.exec_module(denim)
|
|
|
|
base = denim.base
|
|
log = base.log
|
|
FRONT_Y_SIGN = base.FRONT_Y_SIGN # bodies face -Y (verified in base)
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Parameters
|
|
# --------------------------------------------------------------------------
|
|
COVERED_SEGMENTS = [
|
|
"seg_foot_l", "seg_foot_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r",
|
|
]
|
|
|
|
SHAFT_FRAC = 0.35 # boot shaft = this fraction of the ankle->knee span
|
|
BOOT_OFFSET_M = 0.013 # standoff along normals (leather sits off the skin)
|
|
SHAFT_FLARE_M = 0.008 # extra radial stand-off at the shaft rim (feathered)
|
|
SOLE_DROP_M = 0.020 # chunky sole slab depth below the skin sole (spec)
|
|
SOLE_LIP_M = 0.008 # radial sole bulge (chunky work-boot lip)
|
|
SOLE_LIP_TOP_M = 0.020 # lip feather reaches this far above the skin sole
|
|
TEX_SIZE = 1024 # albedo + mask resolution (painted laces need >512)
|
|
NOISE_SEED = 3089
|
|
|
|
# Convex toe box (shared base.convex_toe_box) — chunky work-boot cap.
|
|
TOE_EXT_M = 0.010 # nose extension past the longest toe (m)
|
|
TOE_WMARGIN_M = 0.006 # half-width padding (chunky)
|
|
TOE_HCLEAR_M = 0.009 # vertical headroom above the toes (flex room)
|
|
TOE_FEATHER_M = 0.020 # blend band behind the ball
|
|
|
|
# Painted-detail metrics (metres on average_m; scaled by the calf-span ratio).
|
|
LACE_PITCH_M = 0.017 # vertical distance between lace bars
|
|
LACE_BAR_HW_M = 0.0026 # half-height of a painted lace bar
|
|
LACE_PANEL_HW_M = 0.015 # half arc-width of the lace panel
|
|
EYELET_R_M = 0.0032 # eyelet dot radius (at the panel edges)
|
|
LACE_LO_FRAC = 0.0 # panel starts at the ankle joint (shaft only)
|
|
COLLAR_H_M = 0.014 # padded collar band at the shaft rim (albedo shade)
|
|
SOLE_TOP_M = 0.014 # R region reaches this far above the skin sole
|
|
WELT_OFF_M = 0.0035 # welt stitch line offset above the sole top
|
|
SEAM_HW_M = 0.0018 # painted stitch line half-width
|
|
TOECAP_STITCH = True # painted toe-cap stitch line at the ball joint
|
|
|
|
# Albedo tones (sRGB floats). Mid-luma leather so the toon_garment luma
|
|
# recolor keeps its dynamic range; hue defaults come from manifest tints.
|
|
LEATHER_RGB = (0.58, 0.44, 0.32) # light tan work leather
|
|
SOLE_RGB = (0.30, 0.29, 0.28) # darker rubber (low luma -> reads black)
|
|
LACE_RGB = (0.24, 0.21, 0.19) # dark laces
|
|
EYELET_RGB = (0.85, 0.78, 0.60) # bright metal eyelet glint
|
|
WELT_RGB = (0.78, 0.62, 0.40) # welt stitching thread
|
|
PANEL_SHADE = 0.90 # lace-panel background darkening
|
|
COLLAR_SHADE = 0.86 # padded collar darkening
|
|
ALBEDO_NOISE = 0.020 # +/- grain jitter
|
|
PLAIN = False # --plain: skip laces/eyelets/welt paint
|
|
|
|
# Reference proportions (average_m) the scale ratio is anchored to.
|
|
_REF_CALF_SPAN = 0.4559 # calf head z (0.5424) - calf tail z (0.0865)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-body landmarks
|
|
# --------------------------------------------------------------------------
|
|
|
|
class BootLandmarks:
|
|
"""Cut/mask/paint parameters from one body's calf bones + skin-sole plane."""
|
|
|
|
def __init__(self, armature, skin_min_z):
|
|
bones = armature.data.bones
|
|
calf_l = bones.get("calf_l")
|
|
calf_r = bones.get("calf_r")
|
|
if calf_l is None or calf_r is None:
|
|
raise RuntimeError("calf_l/calf_r missing — not the 65-bone rig?")
|
|
self.ankle_z = (calf_l.tail_local.z + calf_r.tail_local.z) / 2.0
|
|
self.knee_z = (calf_l.head_local.z + calf_r.head_local.z) / 2.0
|
|
self.skin_min_z = skin_min_z
|
|
self.calf_span = self.knee_z - self.ankle_z
|
|
# Detail scale: proportional to the calf span, clamped so small bodies
|
|
# keep believable (not clown, not doll) sole/lace metrics.
|
|
self.s = min(max(self.calf_span / _REF_CALF_SPAN, 0.55), 1.15)
|
|
# Per-z leg axis control points (ankle -> knee) for the +x leg;
|
|
# the right leg mirrors via the x sign. np.interp clamps outside.
|
|
self.leg_z_pts = np.array([calf_l.tail_local.z, calf_l.head_local.z])
|
|
self.leg_x_pts = np.array(
|
|
[abs(calf_l.tail_local.x), abs(calf_l.head_local.x)])
|
|
self.leg_y_pts = np.array([calf_l.tail_local.y, calf_l.head_local.y])
|
|
self.shaft_top_z = self.ankle_z + SHAFT_FRAC * self.calf_span
|
|
self.rim_z = self.shaft_top_z # finalized after rim flattening
|
|
# Ball joint (toe-box hinge): forward distance on the front axis.
|
|
ball = bones.get("ball_l")
|
|
self.ball_front = (ball.head_local.y * FRONT_Y_SIGN
|
|
if ball is not None else None)
|
|
|
|
def finalize(self, rim_z):
|
|
self.rim_z = rim_z
|
|
self.sole_top_z = self.skin_min_z + SOLE_TOP_M * self.s
|
|
self.lace_lo = self.ankle_z - LACE_LO_FRAC * (self.ankle_z - self.skin_min_z)
|
|
self.lace_hi = self.rim_z - 0.7 * COLLAR_H_M * self.s
|
|
log(f"landmarks: ankle={self.ankle_z:.3f} knee={self.knee_z:.3f} "
|
|
f"sole={self.skin_min_z:.3f} rim={self.rim_z:.3f} s={self.s:.2f} "
|
|
f"sole_top={self.sole_top_z:.3f} "
|
|
f"laces z=[{self.lace_lo:.3f},{self.lace_hi:.3f}]")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Geometry: shaft cut + rim flatten + flare + sole
|
|
# --------------------------------------------------------------------------
|
|
|
|
def shaft_cut(shell, lm):
|
|
"""Trim the calves above the boot-shaft plane (bone-derived)."""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
doomed = [v for v in bm.verts if v.co.z > lm.shaft_top_z]
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"shaft cut at z={lm.shaft_top_z:.3f}: removed {len(doomed)} verts")
|
|
|
|
|
|
def flatten_shaft_rims(shell, lm):
|
|
"""Pull both shaft-rim teeth rings onto ONE shared clean plane (pre-offset).
|
|
|
|
The plane cut follows mesh topology, so each rim is a jagged ring of
|
|
teeth. Both rings flatten DOWN to the common valley (deepest notch of
|
|
either ring) — a clean straight rim, identical height on both boots, no
|
|
invented coverage. Any stray open-boundary verts below the shaft (weld
|
|
leftovers) are left alone and reported. Returns the rim plane z.
|
|
"""
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.verts.ensure_lookup_table()
|
|
bm.edges.ensure_lookup_table()
|
|
boundary = set()
|
|
for e in bm.edges:
|
|
if len(e.link_faces) == 1:
|
|
boundary.update(v.index for v in e.verts)
|
|
low_thresh = lm.ankle_z + 0.25 * (lm.shaft_top_z - lm.ankle_z)
|
|
rims = [i for i in boundary if bm.verts[i].co.z > low_thresh]
|
|
stray = len(boundary) - len(rims)
|
|
if not rims:
|
|
bm.free()
|
|
raise RuntimeError("no shaft rim boundary verts found")
|
|
valley = min(bm.verts[i].co.z for i in rims)
|
|
teeth = max(bm.verts[i].co.z for i in rims) - valley
|
|
for i in rims:
|
|
bm.verts[i].co.z = valley
|
|
bm.to_mesh(me)
|
|
bm.free()
|
|
me.update()
|
|
log(f"flattened shaft rims: {len(rims)} verts, teeth {teeth:.3f} m "
|
|
f"-> z={valley:.3f}"
|
|
+ (f" (WARNING: {stray} stray low boundary verts left)" if stray else ""))
|
|
return valley
|
|
|
|
|
|
def shaft_flare(shell, lm, rim_z, flare):
|
|
"""Extra RADIAL stand-off toward the shaft rim, feathered from the ankle
|
|
(post-offset, pre-solidify). Buys pant-hem clearance + chunky read."""
|
|
if flare <= 0.0:
|
|
return
|
|
span = max(rim_z - lm.ankle_z, 1e-6)
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.normal_update()
|
|
n = 0
|
|
for v in bm.verts:
|
|
if v.co.z > lm.ankle_z:
|
|
t = min((v.co.z - lm.ankle_z) / span, 1.0)
|
|
nx, ny = v.normal.x, v.normal.y
|
|
mag = (nx * nx + ny * ny) ** 0.5
|
|
if mag > 1e-6:
|
|
v.co.x += flare * t * nx / mag
|
|
v.co.y += flare * t * ny / mag
|
|
n += 1
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"shaft flare: {n} verts, +{flare * 1000:.1f} mm radial at rim")
|
|
|
|
|
|
def sole_shape(shell, lm, offset, sole_drop):
|
|
"""Chunky sole (post-offset, pre-solidify): feathered radial LIP around
|
|
the sole band, then the under-foot offset surface flattened onto a slab
|
|
plane. Solidify(use_rim) then grows its thickness outward (downward
|
|
there), landing the shipped slab bottom exactly sole_drop below the skin
|
|
sole."""
|
|
lip_top = lm.skin_min_z + SOLE_LIP_TOP_M * lm.s
|
|
lip = SOLE_LIP_M * lm.s
|
|
slab_z = lm.skin_min_z - (sole_drop * lm.s - base.CLOTH_THICKNESS_M)
|
|
feather = max(lip_top - lm.skin_min_z, 1e-6)
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.normal_update()
|
|
n_lip = 0
|
|
for v in bm.verts:
|
|
if v.co.z < lip_top:
|
|
t = min((lip_top - v.co.z) / feather, 1.0)
|
|
nx, ny = v.normal.x, v.normal.y
|
|
mag = (nx * nx + ny * ny) ** 0.5
|
|
if mag > 1e-6:
|
|
v.co.x += lip * t * nx / mag
|
|
v.co.y += lip * t * ny / mag
|
|
n_lip += 1
|
|
n_flat = 0
|
|
for v in bm.verts:
|
|
if v.co.z < lm.skin_min_z:
|
|
v.co.z = slab_z
|
|
n_flat += 1
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"sole: lip +{lip * 1000:.1f} mm on {n_lip} verts, "
|
|
f"{n_flat} under-sole verts flattened -> z={slab_z:.3f} "
|
|
f"(shipped bottom {sole_drop * lm.s * 1000:.0f} mm below skin sole)")
|
|
|
|
|
|
def clamp_rim_residue(shell, rim_z):
|
|
"""Post-solidify safety clamp: verts still above the rim plane (solidify
|
|
displacement on multi-triangle teeth) get squashed onto it."""
|
|
me = shell.data
|
|
n = 0
|
|
for v in me.vertices:
|
|
if v.co.z > rim_z:
|
|
v.co.z = rim_z
|
|
n += 1
|
|
me.update()
|
|
if n:
|
|
log(f"clamped {n} residual rim verts -> {rim_z:.3f}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Boot feature field (texel-level; drives albedo AND mask together)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _boot_field(px, py, pz, lm):
|
|
"""Evaluate boot features at texel 3D positions (numpy arrays)."""
|
|
s = lm.s
|
|
side = np.where(px >= 0.0, 1.0, -1.0)
|
|
cx = side * np.interp(pz, lm.leg_z_pts, lm.leg_x_pts)
|
|
cy = np.interp(pz, lm.leg_z_pts, lm.leg_y_pts)
|
|
dx = px - cx
|
|
dy = py - cy
|
|
r = np.hypot(dx, dy) + 1e-9
|
|
# Arc distance from the front meridian around the per-z leg axis —
|
|
# constant metric width at any girth, mirrors correctly on both boots.
|
|
arc_front = np.arccos(np.clip(dy * FRONT_Y_SIGN / r, -1.0, 1.0)) * r
|
|
|
|
in_sole = pz <= lm.sole_top_z
|
|
in_lace_z = (pz > lm.lace_lo) & (pz < lm.lace_hi) & ~in_sole
|
|
|
|
panel = in_lace_z & (arc_front < LACE_PANEL_HW_M * s)
|
|
pitch = LACE_PITCH_M * s
|
|
ph = np.mod(pz - lm.lace_lo, pitch)
|
|
laces = panel & (np.abs(ph - 0.5 * pitch) < LACE_BAR_HW_M * s)
|
|
eyelets = (
|
|
in_lace_z
|
|
& (np.abs(arc_front - LACE_PANEL_HW_M * s) < EYELET_R_M * s)
|
|
& (np.abs(ph - 0.5 * pitch) < EYELET_R_M * s)
|
|
)
|
|
welt = (~in_sole) & (
|
|
np.abs(pz - (lm.sole_top_z + WELT_OFF_M * s)) < SEAM_HW_M * s)
|
|
if TOECAP_STITCH and lm.ball_front is not None:
|
|
welt = welt | (
|
|
(~in_sole) & (pz < lm.lace_lo)
|
|
& (np.abs(py * FRONT_Y_SIGN - lm.ball_front) < SEAM_HW_M * s))
|
|
collar = (~in_sole) & (pz > lm.rim_z - COLLAR_H_M * s)
|
|
return in_sole, panel, laces, eyelets, welt, collar
|
|
|
|
|
|
def _paint_texels(px, py, pz, noise, lm):
|
|
"""Return (albedo (N,4), mask (N,4)) float32 arrays for texel positions."""
|
|
n = px.shape[0]
|
|
in_sole, panel, laces, eyelets, welt, collar = _boot_field(px, py, pz, lm)
|
|
|
|
# --- albedo ------------------------------------------------------------
|
|
alb = np.empty((n, 4), dtype=np.float32)
|
|
for c in range(3):
|
|
alb[:, c] = LEATHER_RGB[c] + noise
|
|
alb[:, 3] = 1.0
|
|
alb[collar, :3] *= COLLAR_SHADE
|
|
if not PLAIN:
|
|
alb[panel, :3] *= PANEL_SHADE
|
|
for c in range(3):
|
|
alb[welt, c] = WELT_RGB[c]
|
|
for c in range(3):
|
|
alb[in_sole, c] = SOLE_RGB[c] + noise[in_sole]
|
|
if not PLAIN:
|
|
for c in range(3):
|
|
alb[laces, c] = LACE_RGB[c]
|
|
alb[eyelets, c] = EYELET_RGB[c]
|
|
|
|
# --- region mask: sole R / upper+shaft G / laces+eyelets B ---------------
|
|
mask = np.zeros((n, 4), dtype=np.float32)
|
|
is_b = (laces | eyelets) & ~in_sole if not PLAIN else np.zeros(n, dtype=bool)
|
|
is_g = ~(in_sole | is_b)
|
|
mask[in_sole, 0] = 1.0
|
|
mask[is_b, 2] = 1.0
|
|
mask[is_g, 1] = 1.0
|
|
return alb, mask
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# UV0 rasterization (denim technique: one pass paints albedo + mask)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _raster_tri_paint(alb_buf, mask_buf, noise_buf, uvs, cos, lm, W, H):
|
|
"""Barycentric texel fill of one UV triangle: interpolate 3D positions,
|
|
evaluate the boot field, write albedo + mask together."""
|
|
a, b, c = uvs
|
|
A, B, C = cos
|
|
ax, ay = a.x * (W - 1), a.y * (H - 1)
|
|
bx, by = b.x * (W - 1), b.y * (H - 1)
|
|
cx, cy = c.x * (W - 1), c.y * (H - 1)
|
|
minx = max(int(np.floor(min(ax, bx, cx))), 0)
|
|
maxx = min(int(np.ceil(max(ax, bx, cx))), W - 1)
|
|
miny = max(int(np.floor(min(ay, by, cy))), 0)
|
|
maxy = min(int(np.ceil(max(ay, by, cy))), H - 1)
|
|
if minx > maxx or miny > maxy:
|
|
return
|
|
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
|
|
if abs(denom) < 1e-9:
|
|
return
|
|
ys, xs = np.mgrid[miny:maxy + 1, minx:maxx + 1]
|
|
pxg = xs + 0.5
|
|
pyg = ys + 0.5
|
|
w0 = ((by - cy) * (pxg - cx) + (cx - bx) * (pyg - cy)) / denom
|
|
w1 = ((cy - ay) * (pxg - cx) + (ax - cx) * (pyg - cy)) / denom
|
|
w2 = 1.0 - w0 - w1
|
|
inside = (w0 >= -1e-4) & (w1 >= -1e-4) & (w2 >= -1e-4)
|
|
if not inside.any():
|
|
return
|
|
w0i, w1i, w2i = w0[inside], w1[inside], w2[inside]
|
|
px3 = w0i * A.x + w1i * B.x + w2i * C.x
|
|
py3 = w0i * A.y + w1i * B.y + w2i * C.y
|
|
pz3 = w0i * A.z + w1i * B.z + w2i * C.z
|
|
ysin = ys[inside]
|
|
xsin = xs[inside]
|
|
alb, mask = _paint_texels(px3, py3, pz3, noise_buf[ysin, xsin], lm)
|
|
alb_buf[ysin, xsin] = alb
|
|
mask_buf[ysin, xsin] = mask
|
|
|
|
|
|
def paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body):
|
|
"""Rasterize all UV0 triangles once, producing the painted albedo and the
|
|
region mask from one shared feature-field evaluation per texel."""
|
|
W = H = TEX_SIZE
|
|
rng = np.random.default_rng(NOISE_SEED)
|
|
noise_buf = ((rng.random((H, W), dtype=np.float32) - 0.5)
|
|
* 2.0 * ALBEDO_NOISE)
|
|
|
|
alb_buf = np.empty((H, W, 4), dtype=np.float32)
|
|
for c in range(3):
|
|
alb_buf[:, :, c] = LEATHER_RGB[c] + noise_buf
|
|
alb_buf[:, :, 3] = 1.0
|
|
mask_buf = np.zeros((H, W, 4), dtype=np.float32)
|
|
mask_buf[:, :, 1] = 1.0 # background = upper green (bleed-safe)
|
|
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.faces.ensure_lookup_table()
|
|
if not len(bm.loops.layers.uv):
|
|
raise RuntimeError("no UV layer for albedo/mask paint")
|
|
uv_layer = bm.loops.layers.uv[0]
|
|
|
|
tri_count = 0
|
|
for face in bm.faces:
|
|
loops = face.loops[:]
|
|
uvs = [loop[uv_layer].uv.copy() for loop in loops]
|
|
cos = [loop.vert.co.copy() for loop in loops]
|
|
for i in range(1, len(uvs) - 1):
|
|
_raster_tri_paint(
|
|
alb_buf, mask_buf, noise_buf,
|
|
(uvs[0], uvs[i], uvs[i + 1]),
|
|
(cos[0], cos[i], cos[i + 1]),
|
|
lm, W, H)
|
|
tri_count += 1
|
|
bm.free()
|
|
log(f"painted {tri_count} UV triangles -> albedo + mask ({W}x{H})")
|
|
|
|
def _save(buf, name, path):
|
|
img = bpy.data.images.new(name, W, H, alpha=True)
|
|
img.pixels.foreach_set(buf.reshape(-1))
|
|
img.update()
|
|
img.filepath_raw = path
|
|
img.file_format = 'PNG'
|
|
img.save()
|
|
return img
|
|
|
|
albedo_img = _save(alb_buf, f"boot_albedo_{body}", albedo_path)
|
|
_save(mask_buf, f"boot_mask_{body}", mask_path)
|
|
log(f"saved albedo -> {albedo_path}")
|
|
log(f"saved mask -> {mask_path}")
|
|
return albedo_img
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-body authoring
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_boot_shell(body_dir, out_dir, body, offset, sole_drop):
|
|
base.clear_scene()
|
|
base.COVERED_SEGMENTS = COVERED_SEGMENTS
|
|
shell, armature = base.build_covered_mesh(body_dir)
|
|
denim.weld_boundaries(shell) # ankle seams: foot <-> leg_lower
|
|
|
|
skin_min_z = min(v.co.z for v in shell.data.vertices)
|
|
lm = BootLandmarks(armature, skin_min_z)
|
|
|
|
shaft_cut(shell, lm)
|
|
rim_z = flatten_shaft_rims(shell, lm)
|
|
|
|
# Smooth convex toe box (replaces the toe-merge + skin-conforming
|
|
# containment clamp + per-toe weight re-bind, which re-imprinted the
|
|
# individual toes and rippled under flex). The cap encloses the real skin
|
|
# toes and rebinds uniformly to the ball bone; offset then adds standoff.
|
|
base.convex_toe_box(
|
|
shell, armature,
|
|
extension=TOE_EXT_M * lm.s, width_margin=TOE_WMARGIN_M * lm.s,
|
|
height_clear=TOE_HCLEAR_M * lm.s, feather_m=TOE_FEATHER_M * lm.s)
|
|
|
|
base.offset_outward(shell, offset)
|
|
shaft_flare(shell, lm, rim_z, SHAFT_FLARE_M * lm.s)
|
|
sole_shape(shell, lm, offset, sole_drop)
|
|
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
|
clamp_rim_residue(shell, rim_z)
|
|
|
|
lm.finalize(rim_z)
|
|
denim.author_parked_uv2(shell) # boots are not logo-capable
|
|
|
|
albedo_path = os.path.join(out_dir, f"{body}_base_albedo.png")
|
|
mask_path = os.path.join(out_dir, f"{body}_mask.png")
|
|
albedo_img = paint_albedo_and_mask(shell, lm, albedo_path, mask_path, body)
|
|
base.assign_fabric_material(shell, albedo_img)
|
|
|
|
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
|
|
|
|
|
def main():
|
|
global SHAFT_FRAC, SHAFT_FLARE_M, LEATHER_RGB, PLAIN
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
if len(argv) < 2:
|
|
print("Usage: -- <bodies_root> <out_dir> [--bodies a,b,c] "
|
|
"[--offset M] [--shaft-frac F] [--shaft-flare M] "
|
|
"[--sole-drop M] [--base-rgb r,g,b] [--plain]")
|
|
sys.exit(1)
|
|
bodies_root = argv[0]
|
|
out_dir = argv[1]
|
|
offset = BOOT_OFFSET_M
|
|
sole_drop = SOLE_DROP_M
|
|
if "--offset" in argv:
|
|
offset = float(argv[argv.index("--offset") + 1])
|
|
if "--shaft-frac" in argv:
|
|
SHAFT_FRAC = float(argv[argv.index("--shaft-frac") + 1])
|
|
if "--shaft-flare" in argv:
|
|
SHAFT_FLARE_M = float(argv[argv.index("--shaft-flare") + 1])
|
|
if "--sole-drop" in argv:
|
|
sole_drop = float(argv[argv.index("--sole-drop") + 1])
|
|
if "--base-rgb" in argv:
|
|
LEATHER_RGB = tuple(
|
|
float(v) for v in argv[argv.index("--base-rgb") + 1].split(","))
|
|
if "--plain" in argv:
|
|
PLAIN = True
|
|
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)
|
|
log(f"boots per-body mode: {len(bodies)} bodies, offset "
|
|
f"{offset * 1000:.0f} mm, shaft-frac {SHAFT_FRAC}, "
|
|
f"sole-drop {sole_drop * 1000:.0f} mm, plain={PLAIN}")
|
|
|
|
results = []
|
|
for body in bodies:
|
|
body_dir = os.path.join(bodies_root, body)
|
|
log(f"=== {body} ===")
|
|
if not os.path.isdir(body_dir):
|
|
results.append((body, "skipped: body dir missing"))
|
|
continue
|
|
try:
|
|
author_boot_shell(body_dir, out_dir, body, offset, sole_drop)
|
|
results.append((body, "ok"))
|
|
except Exception as exc:
|
|
log(f"ERROR {body}: {exc}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
results.append((body, f"error: {exc}"))
|
|
|
|
ref = base.REFERENCE_BODY
|
|
ref_mask = os.path.join(out_dir, f"{ref}_mask.png")
|
|
if os.path.isfile(ref_mask):
|
|
shutil.copy2(ref_mask, os.path.join(out_dir, "reference_mask.png"))
|
|
log(f"copied {ref}_mask.png -> reference_mask.png (fallback)")
|
|
ref_alb = os.path.join(out_dir, f"{ref}_base_albedo.png")
|
|
if os.path.isfile(ref_alb):
|
|
shutil.copy2(ref_alb, os.path.join(out_dir, "base_albedo.png"))
|
|
log(f"copied {ref}_base_albedo.png -> base_albedo.png (shared sidecar)")
|
|
|
|
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()
|