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>
524 lines
20 KiB
Python
524 lines
20 KiB
Python
"""
|
|
blender_author_lower_shell.py (T-1089, lower-body offset-shell companion)
|
|
|
|
Author LOWER-BODY offset-shell garments (the pants family) per body, reusing
|
|
blender_author_offset_shell.py as a library (scene build, offset, solidify,
|
|
raster helpers, export). The base script stays the torso/t-shirt reference;
|
|
this companion parameterizes the lower-body family so pants_formal, jeans,
|
|
shorts, joggers etc. are all CLI parameter sets over ONE code path — reusable
|
|
parameters over one-off hacks.
|
|
|
|
Covered segments: seg_hips + seg_leg_upper_l/r + seg_leg_lower_l/r.
|
|
Natural boundaries give the waist opening (top of seg_hips) and ankle hems
|
|
(bottom of seg_leg_lower) for free; a leg cut is only applied for --leg-frac
|
|
< 1.0 (shorts family).
|
|
|
|
Region mask (RGBA, UV0-aligned — toon_garment.gdshader convention):
|
|
R = waistband (top band of the hips, height derived from the pelvis bone)
|
|
G = legs (everything else — the main fabric region)
|
|
B = cuffs (optional, --cuff-frac > 0; joggers)
|
|
|
|
Painted texture identity (baked into the per-body albedo, luma-carried so it
|
|
survives any region tint — the shader replaces hue but keeps luminance):
|
|
--crease subtle LIGHT front crease line down each leg (formal press line),
|
|
drawn by intersecting the shell with each leg's hip->knee->ankle
|
|
axis plane and rasterizing the crossing segments in UV space.
|
|
--seam subtle DARK horizontal seam line at the waistband boundary.
|
|
|
|
Per-body mode only (route guidance from Q-060 / 216-capture QA evidence:
|
|
offset-shell garments author per body; weights inherited by construction).
|
|
|
|
Usage:
|
|
reach blender run blender_author_lower_shell \
|
|
<bodies_root> <out_dir> \
|
|
[--bodies a,b,c] [--offset 0.010] [--leg-frac 1.0] \
|
|
[--waistband-frac 1.0] [--cuff-frac 0.0] \
|
|
[--fabric R,G,B] [--fabric-noise 0.012] \
|
|
[--no-crease] [--crease-gain 0.10] [--no-seam] [--seam-gain 0.05] \
|
|
[--seed 1093]
|
|
|
|
Writes per body:
|
|
<out_dir>/<body>.glb skinned garment authored on that body
|
|
<out_dir>/<body>_mask.png RGBA region mask (that body's UV0 layout)
|
|
Plus:
|
|
<out_dir>/base_albedo.png reference body's albedo (sidecar; the
|
|
painted albedo is embedded per GLB, and
|
|
the Godot importer extracts it per body
|
|
as <body>_base_albedo.png)
|
|
<out_dir>/reference_mask.png copy of average_m_mask.png (runtime fallback)
|
|
|
|
Decisions: D-162 (pre-fitted per body), D-251 (in-house wardrobe).
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import sys
|
|
|
|
import bpy
|
|
import bmesh
|
|
import numpy as np
|
|
|
|
# Make the sibling base module importable inside Blender.
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import blender_author_offset_shell as base # noqa: E402
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Lower-body garment parameters (CLI-overridable)
|
|
# --------------------------------------------------------------------------
|
|
|
|
LOWER_SEGMENTS = [
|
|
"seg_hips",
|
|
"seg_leg_upper_l", "seg_leg_upper_r",
|
|
"seg_leg_lower_l", "seg_leg_lower_r",
|
|
]
|
|
|
|
DEFAULTS = {
|
|
"offset": 0.010, # slim silhouette standoff (m); per-body authoring
|
|
# guarantees clearance by construction, and bottoms
|
|
# sit UNDER tops (t-shirt hem = 12 mm), so 10 mm
|
|
# keeps the layering order correct at the waist.
|
|
"leg_frac": 1.0, # fraction of hip->ankle length kept (1.0 = full)
|
|
"waistband_frac": 1.0, # waistband starts at pelvis head + frac*bone_len
|
|
"cuff_frac": 0.0, # B-region cuff height as fraction of knee->ankle
|
|
"fabric": (0.145, 0.147, 0.160), # dark charcoal, slight cool cast
|
|
"fabric_noise": 0.012, # +/- albedo jitter, subtle woven feel
|
|
"crease": True, # painted front crease line per leg
|
|
"crease_gain": 0.10, # luma lift along the crease (subtle press line)
|
|
"seam": True, # painted seam at the waistband boundary
|
|
"seam_gain": 0.05, # luma drop along the seam
|
|
"seed": 1093, # deterministic albedo noise
|
|
}
|
|
|
|
CREASE_TOP_MARGIN = 0.12 # crease starts this fraction below the hip joint
|
|
CREASE_NORMAL_MIN = 0.35 # face must point forward this much for the crease
|
|
LINE_RADIUS_PX = 0.9 # painted line half-width in texels
|
|
|
|
|
|
def log(msg):
|
|
print(f"[lower-shell] {msg}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Threshold derivation (bone landmarks; the 11 bodies share the 65-bone rig)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def derive_lower_thresholds(armature, p):
|
|
"""Derive waistband/cuff z-planes and per-leg axis polylines.
|
|
|
|
Anchors: pelvis (waistband band), thigh_* head (hip joint), thigh_* tail /
|
|
calf_* head (knee), calf_* tail (ankle). All in body-local metres so each
|
|
body derives its OWN proportionally-consistent parameters.
|
|
"""
|
|
bones = armature.data.bones
|
|
pelvis = bones.get("pelvis")
|
|
need = ["thigh_l", "thigh_r", "calf_l", "calf_r"]
|
|
legs = {}
|
|
for side in ("l", "r"):
|
|
thigh = bones.get(f"thigh_{side}")
|
|
calf = bones.get(f"calf_{side}")
|
|
if thigh is None or calf is None:
|
|
raise RuntimeError(f"landmark bones missing for leg _{side} ({need})")
|
|
# Polyline hip -> knee -> ankle in the XZ plane (rest pose, z-up).
|
|
legs[side] = {
|
|
"hip": (thigh.head_local.x, thigh.head_local.z),
|
|
"knee": (thigh.tail_local.x, thigh.tail_local.z),
|
|
"ankle": (calf.tail_local.x, calf.tail_local.z),
|
|
}
|
|
if pelvis is None:
|
|
raise RuntimeError("pelvis bone missing — cannot derive waistband")
|
|
|
|
pelvis_len = pelvis.tail_local.z - pelvis.head_local.z
|
|
wb_z = pelvis.head_local.z + p["waistband_frac"] * pelvis_len
|
|
|
|
hip_z = (legs["l"]["hip"][1] + legs["r"]["hip"][1]) / 2.0
|
|
ankle_z = (legs["l"]["ankle"][1] + legs["r"]["ankle"][1]) / 2.0
|
|
knee_z = (legs["l"]["knee"][1] + legs["r"]["knee"][1]) / 2.0
|
|
leg_len = hip_z - ankle_z
|
|
|
|
cut_z = None
|
|
if p["leg_frac"] < 0.999:
|
|
cut_z = hip_z - p["leg_frac"] * leg_len
|
|
|
|
cuff_z = None
|
|
if p["cuff_frac"] > 1e-6:
|
|
cuff_z = ankle_z + p["cuff_frac"] * (knee_z - ankle_z)
|
|
|
|
thr = {
|
|
"wb_z": wb_z,
|
|
"cut_z": cut_z,
|
|
"cuff_z": cuff_z,
|
|
"legs": legs,
|
|
"hip_z": hip_z,
|
|
"ankle_z": ankle_z,
|
|
"leg_len": leg_len,
|
|
}
|
|
log(f"thresholds: waistband z>={wb_z:.3f} "
|
|
f"cut_z={cut_z if cut_z is None else round(cut_z, 3)} "
|
|
f"cuff_z={cuff_z if cuff_z is None else round(cuff_z, 3)} "
|
|
f"hip_z={hip_z:.3f} ankle_z={ankle_z:.3f}")
|
|
return thr
|
|
|
|
|
|
def _leg_axis_x(leg, z):
|
|
"""Piecewise-linear x of the leg axis at height z (clamped to endpoints)."""
|
|
hx, hz = leg["hip"]
|
|
kx, kz = leg["knee"]
|
|
ax, az = leg["ankle"]
|
|
if z >= hz:
|
|
return hx
|
|
if z <= az:
|
|
return ax
|
|
if z >= kz:
|
|
t = (hz - z) / max(hz - kz, 1e-6)
|
|
return hx + t * (kx - hx)
|
|
t = (kz - z) / max(kz - az, 1e-6)
|
|
return kx + t * (ax - kx)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Seam weld (pre-offset)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def weld_seams(shell):
|
|
"""Weld coincident verts before offsetting.
|
|
|
|
glTF import splits vertices along UV seams (and the joined segment
|
|
boundaries duplicate their shared rings), so offsetting each copy along
|
|
its OWN split normal tears the shell open — on the pants this showed as a
|
|
skin-coloured slit down the front-centre seam of the hips. Welding fuses
|
|
the copies so the offset moves one vertex along one averaged normal.
|
|
Per-loop UVs are untouched (the UV seam itself survives); coincident
|
|
copies carry identical bone weights by construction, so skinning is
|
|
unaffected.
|
|
"""
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
before = len(bm.verts)
|
|
bmesh.ops.remove_doubles(bm, verts=bm.verts[:], dist=1e-4)
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"welded seams: {before} -> {len(shell.data.vertices)} verts")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Leg cut (shorts family; skipped at leg_frac 1.0)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def leg_cut(shell, cut_z):
|
|
bm = bmesh.new()
|
|
bm.from_mesh(shell.data)
|
|
bm.verts.ensure_lookup_table()
|
|
doomed = [v for v in bm.verts if v.co.z < cut_z]
|
|
bmesh.ops.delete(bm, geom=doomed, context='VERTS')
|
|
bm.to_mesh(shell.data)
|
|
bm.free()
|
|
shell.data.update()
|
|
log(f"leg cut at z={cut_z:.3f}: removed {len(doomed)} verts; "
|
|
f"{len(shell.data.vertices)} remain")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Painted albedo (charcoal base + crease/seam lines, luma-carried identity)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _plane_crossings_uv(face, uv_layer, dist_fn):
|
|
"""UV points where the face's edge loop crosses dist_fn(co) == 0."""
|
|
loops = face.loops[:]
|
|
n = len(loops)
|
|
pts = []
|
|
for i in range(n):
|
|
la, lb = loops[i], loops[(i + 1) % n]
|
|
d1 = dist_fn(la.vert.co)
|
|
d2 = dist_fn(lb.vert.co)
|
|
if (d1 > 0.0) == (d2 > 0.0):
|
|
continue
|
|
denom = d1 - d2
|
|
if abs(denom) < 1e-9:
|
|
continue
|
|
t = d1 / denom
|
|
uv1 = la[uv_layer].uv
|
|
uv2 = lb[uv_layer].uv
|
|
pts.append((uv1[0] + t * (uv2[0] - uv1[0]),
|
|
uv1[1] + t * (uv2[1] - uv1[1])))
|
|
return pts
|
|
|
|
|
|
def _stamp_uv_line(hits, a, b, W, H):
|
|
"""Mark texels along UV segment a->b into boolean mask `hits`."""
|
|
ax, ay = a[0] * (W - 1), a[1] * (H - 1)
|
|
bx, by = b[0] * (W - 1), b[1] * (H - 1)
|
|
steps = int(max(abs(bx - ax), abs(by - ay)) * 2.0) + 1
|
|
r = LINE_RADIUS_PX
|
|
for i in range(steps + 1):
|
|
t = i / steps
|
|
px = ax + (bx - ax) * t
|
|
py = ay + (by - ay) * t
|
|
x0 = max(int(np.floor(px - r)), 0)
|
|
x1 = min(int(np.ceil(px + r)), W - 1)
|
|
y0 = max(int(np.floor(py - r)), 0)
|
|
y1 = min(int(np.ceil(py + r)), H - 1)
|
|
for yy in range(y0, y1 + 1):
|
|
for xx in range(x0, x1 + 1):
|
|
if (xx - px) ** 2 + (yy - py) ** 2 <= r * r + 0.25:
|
|
hits[yy, xx] = True
|
|
|
|
|
|
def bake_painted_albedo(shell, thr, p):
|
|
"""Charcoal fabric + painted crease/seam lines, baked in UV0 space.
|
|
|
|
The toon_garment shader keeps only albedo LUMINANCE under region tints, so
|
|
identity painted here (light crease, dark seam) survives any recolor.
|
|
"""
|
|
W = H = base.ALBEDO_SIZE
|
|
rng = np.random.default_rng(p["seed"])
|
|
fabric = np.array(p["fabric"], dtype=np.float32)
|
|
noise = (rng.random((H, W, 1), dtype=np.float32) - 0.5) * 2.0 * p["fabric_noise"]
|
|
rgb = np.clip(fabric[None, None, :] + noise, 0.0, 1.0)
|
|
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.faces.ensure_lookup_table()
|
|
bm.normal_update()
|
|
uv_layer = bm.loops.layers.uv.active
|
|
if uv_layer is None:
|
|
raise RuntimeError("no active UV layer for albedo bake")
|
|
|
|
crease_hits = np.zeros((H, W), dtype=bool)
|
|
seam_hits = np.zeros((H, W), dtype=bool)
|
|
crease_faces = 0
|
|
seam_faces = 0
|
|
|
|
crease_top = thr["hip_z"] - CREASE_TOP_MARGIN * thr["leg_len"]
|
|
for face in bm.faces:
|
|
center = face.calc_center_median()
|
|
# Waistband seam: any face crossing the wb_z plane (full ring).
|
|
if p["seam"]:
|
|
pts = _plane_crossings_uv(face, uv_layer,
|
|
lambda co: co.z - thr["wb_z"])
|
|
if len(pts) >= 2:
|
|
_stamp_uv_line(seam_hits, pts[0], pts[1], W, H)
|
|
seam_faces += 1
|
|
# Front crease: forward-facing faces crossing the leg-axis plane.
|
|
if p["crease"]:
|
|
if face.normal.y * base.FRONT_Y_SIGN < CREASE_NORMAL_MIN:
|
|
continue
|
|
if center.z > crease_top or center.z < thr["ankle_z"]:
|
|
continue
|
|
leg = thr["legs"]["l"] if center.x >= 0.0 else thr["legs"]["r"]
|
|
pts = _plane_crossings_uv(
|
|
face, uv_layer, lambda co: co.x - _leg_axis_x(leg, co.z))
|
|
if len(pts) >= 2:
|
|
_stamp_uv_line(crease_hits, pts[0], pts[1], W, H)
|
|
crease_faces += 1
|
|
bm.free()
|
|
|
|
if p["seam"]:
|
|
rgb[seam_hits, :] = np.clip(rgb[seam_hits, :] - p["seam_gain"], 0.0, 1.0)
|
|
if p["crease"]:
|
|
rgb[crease_hits, :] = np.clip(rgb[crease_hits, :] + p["crease_gain"], 0.0, 1.0)
|
|
log(f"painted albedo: crease faces={crease_faces} "
|
|
f"({int(crease_hits.sum())} px) seam faces={seam_faces} "
|
|
f"({int(seam_hits.sum())} px)")
|
|
if p["crease"] and crease_faces == 0:
|
|
log("WARNING: crease painted on 0 faces — check leg axis / front sign")
|
|
|
|
rgba = np.concatenate(
|
|
[rgb, np.ones((H, W, 1), dtype=np.float32)], axis=2)
|
|
img = bpy.data.images.new("garment_lower_albedo", W, H, alpha=False)
|
|
img.pixels.foreach_set(rgba.reshape(-1))
|
|
img.update()
|
|
return img
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Region mask (waistband R / legs G / optional cuff B)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def _classify_lower(center, thr):
|
|
if center.z >= thr["wb_z"]:
|
|
return (1.0, 0.0, 0.0, 0.0) # waistband -> R (tint_0)
|
|
if thr["cuff_z"] is not None and center.z <= thr["cuff_z"]:
|
|
return (0.0, 0.0, 1.0, 0.0) # cuff -> B (tint_2)
|
|
return (0.0, 1.0, 0.0, 0.0) # legs -> G (tint_1)
|
|
|
|
|
|
def bake_lower_mask(shell, out_path, thr):
|
|
W = H = base.MASK_SIZE
|
|
buf = np.zeros((H, W, 4), dtype=np.float32)
|
|
buf[:, :, 1] = 1.0 # green background = legs (main region)
|
|
|
|
me = shell.data
|
|
bm = bmesh.new()
|
|
bm.from_mesh(me)
|
|
bm.faces.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")
|
|
|
|
counts = {"waistband": 0, "legs": 0, "cuff": 0}
|
|
for face in bm.faces:
|
|
color = _classify_lower(face.calc_center_median(), thr)
|
|
if color[0] > 0.5:
|
|
counts["waistband"] += 1
|
|
elif color[2] > 0.5:
|
|
counts["cuff"] += 1
|
|
else:
|
|
counts["legs"] += 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()))
|
|
if counts["waistband"] == 0:
|
|
log("WARNING: waistband region empty — check waistband_frac / pelvis")
|
|
|
|
img = bpy.data.images.new("garment_lower_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}")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Parked logo UV2 (pants are not logo-capable; keep the channel guarded)
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_parked_logo_uv(shell):
|
|
me = shell.data
|
|
while len(me.uv_layers) > 1:
|
|
me.uv_layers.remove(me.uv_layers[-1])
|
|
logo_uv = me.uv_layers.new(name="logo_uv")
|
|
me.uv_layers.active = me.uv_layers[0]
|
|
data = logo_uv.data
|
|
parked = [2.0, 2.0] * len(data)
|
|
logo_uv.data.foreach_set("uv", parked)
|
|
me.update()
|
|
log(f"logo UV2 parked outside [0,1] on all {len(data)} loops")
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Per-body authoring
|
|
# --------------------------------------------------------------------------
|
|
|
|
def author_lower_shell(body_dir, out_dir, body, p):
|
|
base.clear_scene()
|
|
base.COVERED_SEGMENTS = LOWER_SEGMENTS # reuse the base segment importer
|
|
shell, armature = base.build_covered_mesh(body_dir)
|
|
thr = derive_lower_thresholds(armature, p)
|
|
|
|
zs = [v.co.z for v in shell.data.vertices]
|
|
log(f"shell z-range: {min(zs):.3f}..{max(zs):.3f}")
|
|
if thr["wb_z"] >= max(zs):
|
|
log("WARNING: waistband plane above shell top — band will be empty")
|
|
|
|
weld_seams(shell)
|
|
if thr["cut_z"] is not None:
|
|
leg_cut(shell, thr["cut_z"])
|
|
base.offset_outward(shell, p["offset"])
|
|
base.solidify(shell, base.CLOTH_THICKNESS_M)
|
|
|
|
albedo_img = bake_painted_albedo(shell, thr, p)
|
|
base.assign_fabric_material(shell, albedo_img)
|
|
author_parked_logo_uv(shell)
|
|
bake_lower_mask(shell, os.path.join(out_dir, f"{body}_mask.png"), thr)
|
|
|
|
# Save under the SHARED sidecar name for every body: the glTF exporter
|
|
# names the embedded image after this filepath, and the Godot importer
|
|
# extracts embedded textures as <glb>_<imagename>.png — so this yields the
|
|
# per-body <body>_base_albedo.png convention (as tshirt_modern) on import.
|
|
# The loop authors the reference body LAST so base_albedo.png ends up as
|
|
# the reference albedo (per-body albedos differ: painted crease follows
|
|
# each body's own geometry).
|
|
base.save_albedo_sidecar(
|
|
albedo_img, os.path.join(out_dir, "base_albedo.png"))
|
|
|
|
base.export_reference(shell, armature, os.path.join(out_dir, f"{body}.glb"))
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Entry
|
|
# --------------------------------------------------------------------------
|
|
|
|
def parse_args(argv):
|
|
p = dict(DEFAULTS)
|
|
# Reference body last: the shared base_albedo.png sidecar is rewritten per
|
|
# body, so ordering makes the surviving copy the reference body's.
|
|
bodies = [b for b in base.BODY_TYPES if b != base.REFERENCE_BODY]
|
|
bodies.append(base.REFERENCE_BODY)
|
|
|
|
def _val(flag):
|
|
return argv[argv.index(flag) + 1]
|
|
|
|
if "--bodies" in argv:
|
|
bodies = [s.strip() for s in _val("--bodies").split(",")]
|
|
for flag, key, cast in [
|
|
("--offset", "offset", float),
|
|
("--leg-frac", "leg_frac", float),
|
|
("--waistband-frac", "waistband_frac", float),
|
|
("--cuff-frac", "cuff_frac", float),
|
|
("--fabric-noise", "fabric_noise", float),
|
|
("--crease-gain", "crease_gain", float),
|
|
("--seam-gain", "seam_gain", float),
|
|
("--seed", "seed", int),
|
|
]:
|
|
if flag in argv:
|
|
p[key] = cast(_val(flag))
|
|
if "--fabric" in argv:
|
|
p["fabric"] = tuple(float(s) for s in _val("--fabric").split(","))
|
|
if "--no-crease" in argv:
|
|
p["crease"] = False
|
|
if "--no-seam" in argv:
|
|
p["seam"] = False
|
|
return p, bodies
|
|
|
|
|
|
def main():
|
|
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
|
if len(argv) < 2:
|
|
print(__doc__)
|
|
sys.exit(1)
|
|
bodies_root = argv[0]
|
|
out_dir = argv[1]
|
|
p, bodies = parse_args(argv)
|
|
os.makedirs(out_dir, exist_ok=True)
|
|
|
|
log(f"lower-shell per-body: {len(bodies)} bodies, offset "
|
|
f"{p['offset']*1000:.0f} mm, leg_frac {p['leg_frac']}, "
|
|
f"fabric {p['fabric']}")
|
|
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_lower_shell(body_dir, out_dir, body, p)
|
|
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")
|
|
|
|
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()
|