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>
525 lines
20 KiB
Python
525 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:
|
|
tooling/blender --background --python \
|
|
tooling/garment-fit/blender_author_lower_shell.py -- \
|
|
<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()
|