Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 82 additions & 17 deletions src/meshops/proportion/blockout_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,13 @@
CROTCH_Z_FRAC_FALLBACK: Final[float] = 0.5
_NEAR_ZERO_LEN: Final[float] = 1e-9
# 0045 B1: calf belly + asymmetric ends (replaces single _CALF_END_SCALE=0.95).
# 0071 B10-B11: p0-only lateral/rear belly bias (scales kept).
_CALF_END_R_FLOOR: Final[float] = 1e-4
CALF_BELLY_SCALE: Final[float] = 1.08
CALF_PROX_END_SCALE: Final[float] = 0.88
CALF_DIST_END_SCALE: Final[float] = 0.72
CALF_BELLY_LAT_FRAC: Final[float] = 0.22 # B10: outer offset on cyl.p0 only (* cyl_r)
CALF_BELLY_REAR_FRAC: Final[float] = 0.28 # B11: rear offset on cyl.p0 only (* cyl_r, +Y)
# 0045 B3: arm distal soft beads only (not thigh — P2-1 / B13).
# 0062 B9: shrink to forearm only (elbow_soft owns UA distal joint; no UA dist_soft).
LIMB_DISTAL_SOFT_SCALE: Final[float] = 0.78
Expand All @@ -90,9 +93,13 @@
ELBOW_SOFT_SCALE: Final[float] = 1.10 # B10 — readable bulge, NOT 0.55
ELBOW_SOFT_MIN_FRAC_H: Final[float] = 0.016
WRIST_SOFT_PALM_RX_FRAC: Final[float] = 0.85 # B11
# 0045 B5: knee soft joint mass.
KNEE_SOFT_FRAC: Final[float] = 0.55
KNEE_SOFT_MIN_FRAC_H: Final[float] = 0.018
# --- Knee joint mass (0045 retune → 0071) ---
KNEE_SOFT_FRAC: Final[float] = 1.10 # B1: scale vs SEAM adj (not full-leg max)
KNEE_SOFT_MIN_FRAC_H: Final[float] = 0.018 # B2: stature floor
KNEE_SOFT_RY_FRAC: Final[float] = 0.90 # B3: depth de-sphere
KNEE_SOFT_RZ_FRAC: Final[float] = 0.75 # B4: vertical de-sphere
KNEE_SOFT_OUTER_FRAC_RX: Final[float] = 0.06 # B5: outer center bias (* rx, signed)
KNEE_SOFT_REAR_FRAC_RY: Final[float] = 0.10 # B6: rear +Y center bias (* ry)
# 0046 B1: deltoid scale vs upper_arm half-width (profile + base).
DELT_ARM_RADIUS_SCALE: Final[float] = 1.35
DELT_RY_FRAC: Final[float] = 0.90
Expand Down Expand Up @@ -1937,6 +1944,11 @@ def _build_calf_split(
name_a = f"RECIPE_calf_a_{side}"
name_cyl = f"RECIPE_calf_cyl_{side}"
name_b = f"RECIPE_calf_b_{side}"
# a/b stay on joint axis; cyl p0 gets 0071 belly bias (p1 unchanged — B6-safe).
sign = 1.0 if side == "r" else -1.0
dx = sign * CALF_BELLY_LAT_FRAC * cyl_r
dy = CALF_BELLY_REAR_FRAC * cyl_r
cyl_p0 = [float(p0[0]) + dx, float(p0[1]) + dy, float(p0[2])]
parts = [
RecipePart(
name=name_a,
Expand All @@ -1953,7 +1965,7 @@ def _build_calf_split(
name=name_cyl,
role="limb_segment",
kind="capsule",
p0=[float(p0[0]), float(p0[1]), float(p0[2])],
p0=cyl_p0,
p1=[float(p1[0]), float(p1[1]), float(p1[2])],
radius_m=cyl_r,
placement=placement,
Expand All @@ -1975,6 +1987,7 @@ def _build_calf_split(
if placement == "front_plane":
note += " (front_plane)"
messages.append(note)
messages.append(f"calf_{side}: belly bias p0 lat={abs(dx):.4f} rear={dy:.4f}")
return parts


Expand Down Expand Up @@ -2211,10 +2224,10 @@ def _knee_adj_radius_m(
side: str,
report: ProportionReport,
) -> float | None:
"""0045 B5 + 0070 B9: adjacent shaft radius for knee soft (name-keyed primary).
"""0045 B5 + 0070 B9: full-leg adjacent max (no-shrink fence for engagement/0070).

max(prox, dist if present, calf_a…) so knee does not shrink when shaft tapers.
Note (AI2 P3-4): if PROX_SHAFT later >1.0, knee soft grows — intentional no-shrink.
max(prox, dist if present, calf_a…) — NOT the product scale source after 0071.
Scale path uses `_knee_seam_radius_m` (elbow-class seam).
"""
by = {p.name: p for p in parts}
candidates: list[float] = []
Expand All @@ -2240,6 +2253,40 @@ def _knee_adj_radius_m(
return max(candidates) if candidates else None


def _knee_seam_radius_m(
parts: list[RecipePart],
side: str,
report: ProportionReport,
) -> float | None:
"""0071 B12: elbow-class SEAM adj — max(distal thigh end, calf_a), not prox mid.

Prefer thigh_taper_dist when present; else limb_thigh; always consider calf_a.
Diameter ladder fallback only when both shaft ends absent.
"""
by = {p.name: p for p in parts}
cands: list[float] = []
dist = by.get(f"RECIPE_thigh_taper_dist_{side}")
thigh = by.get(f"RECIPE_limb_thigh_{side}")
if dist is not None and dist.radius_m is not None:
cands.append(float(dist.radius_m))
elif thigh is not None and thigh.radius_m is not None:
cands.append(float(thigh.radius_m))
calf_a = by.get(f"RECIPE_calf_a_{side}")
if calf_a is not None and calf_a.rx_m is not None:
cands.append(float(calf_a.rx_m))
if cands:
return max(cands)
# Diameter fallback (same spirit as _knee_adj_radius_m when shaft ends absent)
th = _resolve_diameter(report.diameters, f"thigh_{side}")
ca = _resolve_diameter(report.diameters, f"calf_{side}")
for d in (th, ca):
if d is not None:
hw = _half_width_from_diameter(d)
if hw is not None:
cands.append(float(hw))
return max(cands) if cands else None


def _knee_center_and_placement(
report: ProportionReport,
side: str,
Expand Down Expand Up @@ -2276,35 +2323,45 @@ def _append_knee_softs(
height_m: float | None,
messages: list[str],
) -> None:
"""0045 B5: post-pass knee soft ellipsoids after limbs emit."""
"""0045 B5 + 0071: seam-scaled anisotropic knee softs after limbs emit (pre-adduction)."""
for side in ("l", "r"):
center_place = _knee_center_and_placement(report, side, skeleton)
if center_place is None:
continue # skip missing joint — not fail
center, placement = center_place
adj = _knee_adj_radius_m(parts, side, report)
r: float | None = KNEE_SOFT_FRAC * adj if adj is not None else None
adj = _knee_seam_radius_m(parts, side, report)
base: float | None = KNEE_SOFT_FRAC * adj if adj is not None else None
if height_m is not None and height_m > 0:
floor = KNEE_SOFT_MIN_FRAC_H * float(height_m)
r = max(r, floor) if r is not None else floor
if r is None:
base = max(base, floor) if base is not None else floor
if base is None:
continue
rx = max(base, 1e-4)
ry = max(base * KNEE_SOFT_RY_FRAC, 1e-4)
rz = max(base * KNEE_SOFT_RZ_FRAC, 1e-4)
sign = 1.0 if side == "r" else -1.0
cx = float(center[0]) + sign * KNEE_SOFT_OUTER_FRAC_RX * rx
cy = float(center[1]) + KNEE_SOFT_REAR_FRAC_RY * ry
cz = float(center[2])
# Placement (P3-7b): full3d when |cy| >= 1e-3 after bias; else keep original
if abs(cy) >= 1e-3:
placement = "full3d"
name = f"RECIPE_knee_soft_{side}"
_append_part(
parts,
RecipePart(
name=name,
role="limb_segment",
kind="ellipsoid",
center=center,
rx_m=r,
ry_m=r,
rz_m=r,
center=[cx, cy, cz],
rx_m=rx,
ry_m=ry,
rz_m=rz,
placement=placement,
label=name,
),
)
messages.append(f"knee_soft_{side}: r={r:.4f}")
messages.append(f"knee_soft_{side}: rx={rx:.4f} ry={ry:.4f} rz={rz:.4f}")


def _append_all_hip_softs(
Expand Down Expand Up @@ -5204,6 +5261,8 @@ def run_blockout_emit_setup(
__all__ = [
"AXIS_NOTES",
"BPY_BASENAME",
"CALF_BELLY_LAT_FRAC",
"CALF_BELLY_REAR_FRAC",
"CALF_BELLY_SCALE",
"CALF_DIST_END_SCALE",
"CALF_PROX_END_SCALE",
Expand Down Expand Up @@ -5240,6 +5299,10 @@ def run_blockout_emit_setup(
"JSON_BASENAME",
"KNEE_SOFT_FRAC",
"KNEE_SOFT_MIN_FRAC_H",
"KNEE_SOFT_OUTER_FRAC_RX",
"KNEE_SOFT_REAR_FRAC_RY",
"KNEE_SOFT_RY_FRAC",
"KNEE_SOFT_RZ_FRAC",
"LIMB_DISTAL_SOFT_SCALE",
"MIDLINE_X_TOL_M",
"NECK_FORWARD_TILT_DEG",
Expand Down Expand Up @@ -5291,6 +5354,8 @@ def run_blockout_emit_setup(
"_build_thigh_tapered",
"_co_shift_thigh_taper_dist",
"_glute_or_hip_half_depth_m",
"_knee_adj_radius_m",
"_knee_seam_radius_m",
"_midpoint_of_joints",
"_pelvis_ref_rear_y",
"build_blockout_recipe",
Expand Down
46 changes: 34 additions & 12 deletions tests/test_proportion_blockout_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,8 +439,10 @@ def test_recipe__limbs_y_null_front_plane() -> None:


def test_recipe__limbs_emit_split_calf() -> None:
"""0034 names + 0045 B1/B2: calf belly + asymmetric ends; no RECIPE_limb_calf_*."""
"""0034 names + 0045 B1/B2 + 0071 p0 belly: split calf; no RECIPE_limb_calf_*."""
from meshops.proportion.blockout_recipe import (
CALF_BELLY_LAT_FRAC,
CALF_BELLY_REAR_FRAC,
CALF_BELLY_SCALE,
CALF_DIST_END_SCALE,
CALF_PROX_END_SCALE,
Expand Down Expand Up @@ -491,11 +493,17 @@ def test_recipe__limbs_emit_split_calf() -> None:
assert float(a.center[1]) == pytest.approx(knee_y)
assert float(b.center[1]) == pytest.approx(ankle_y)
assert cyl.p0 is not None and cyl.p1 is not None
assert float(cyl.p0[1]) == pytest.approx(knee_y)
# 0071: p0-only lat+rear belly; p1 stays on ankle joint
sign = 1.0 if side == "r" else -1.0
assert float(cyl.p0[0]) == pytest.approx(
float(a.center[0]) + sign * CALF_BELLY_LAT_FRAC * cyl_r, abs=1e-9
)
assert float(cyl.p0[1]) == pytest.approx(knee_y + CALF_BELLY_REAR_FRAC * cyl_r, abs=1e-9)
assert float(cyl.p1[1]) == pytest.approx(ankle_y)
assert a.placement == "full3d"
# T2: belly/taper message
# T2: belly/taper + p0 bias messages
assert any(f"calf_{side}: belly/taper a=" in m for m in pkg.messages)
assert any(f"calf_{side}: belly bias p0 lat=" in m for m in pkg.messages)

assert not any("limb_calf" in p.name.lower() for p in pkg.parts)
# T8: no RECIPE_limb_calf on limbs path
Expand Down Expand Up @@ -552,8 +560,12 @@ def test_recipe__calf_distal_syncs_to_ank_foot() -> None:
ay = float(ank.center[1])
assert float(dist.center[1]) == pytest.approx(ay)
assert float(cyl.p1[1]) == pytest.approx(ay)
# Proximal Y stays at knee (0.0 in this fixture)
assert float(cyl.p0[1]) == pytest.approx(0.0)
# 0071: p0 has rear belly bias; B6 never rewrites p0 Y (only p1)
from meshops.proportion.blockout_recipe import CALF_BELLY_REAR_FRAC

assert cyl.radius_m is not None
expected_p0_y = 0.0 + CALF_BELLY_REAR_FRAC * float(cyl.radius_m)
assert float(cyl.p0[1]) == pytest.approx(expected_p0_y, abs=1e-6)
# B6 Y rewrite from ank_foot → placement is full3d (even if emit was front_plane)
assert dist.placement == "full3d"
assert cyl.placement == "full3d"
Expand Down Expand Up @@ -760,15 +772,18 @@ def test_recipe__t4_arm_dist_soft_scale() -> None:


def test_recipe__t5_knee_soft_radius_mixed_thigh_calf() -> None:
"""0045 T5: knee_soft r = max(0.55*max(thigh.r, calf_a.rx), 0.018*H)."""
"""0071 T5 pin: knee_soft rx = 1.10*seam; seam=max(taper_dist else thigh, calf_a)."""
from meshops.proportion.blockout_recipe import (
CALF_PROX_END_SCALE,
KNEE_SOFT_FRAC,
KNEE_SOFT_MIN_FRAC_H,
KNEE_SOFT_RY_FRAC,
KNEE_SOFT_RZ_FRAC,
THIGH_DIST_SHAFT_SCALE,
)

height_m = 1.72
thigh_hw = 0.08 # larger than calf_a.rx so max = thigh
thigh_hw = 0.08 # prox > calf_a; taper_dist = 0.08*0.8 = 0.064 > calf_a
calf_hw = 0.04
report = _limb_mass_report(
height_m=height_m,
Expand All @@ -780,18 +795,25 @@ def test_recipe__t5_knee_soft_radius_mixed_thigh_calf() -> None:
for side in ("l", "r"):
knee = by_name[f"RECIPE_knee_soft_{side}"]
thigh = by_name[f"RECIPE_limb_thigh_{side}"]
dist = by_name[f"RECIPE_thigh_taper_dist_{side}"]
calf_a = by_name[f"RECIPE_calf_a_{side}"]
assert knee.kind == "ellipsoid"
assert knee.role == "limb_segment"
adj = max(float(thigh.radius_m), float(calf_a.rx_m)) # type: ignore[arg-type]
expected = max(KNEE_SOFT_FRAC * adj, KNEE_SOFT_MIN_FRAC_H * height_m)
# Mixed: thigh 0.08 > calf_a.rx = 0.04*0.88 → adj = thigh
# Seam: prefer taper_dist over prox; include calf_a
seam = max(float(dist.radius_m), float(calf_a.rx_m)) # type: ignore[arg-type]
base = max(KNEE_SOFT_FRAC * seam, KNEE_SOFT_MIN_FRAC_H * height_m)
assert float(calf_a.rx_m) == pytest.approx( # type: ignore[arg-type]
calf_hw * CALF_PROX_END_SCALE, abs=1e-9
)
assert float(dist.radius_m) == pytest.approx( # type: ignore[arg-type]
thigh_hw * THIGH_DIST_SHAFT_SCALE, abs=1e-9
)
# thigh prox still > calf_a (mixed mass context); do NOT require rx from prox max
assert float(thigh.radius_m) > float(calf_a.rx_m) # type: ignore[arg-type]
assert knee.rx_m == pytest.approx(expected, abs=1e-9)
assert any(f"knee_soft_{side}: r=" in m for m in pkg.messages)
assert knee.rx_m == pytest.approx(base, abs=1e-9)
assert knee.ry_m == pytest.approx(base * KNEE_SOFT_RY_FRAC, abs=1e-9)
assert knee.rz_m == pytest.approx(base * KNEE_SOFT_RZ_FRAC, abs=1e-9)
assert any(f"knee_soft_{side}: rx=" in m for m in pkg.messages)
assert knee.center is not None
assert float(knee.center[2]) == pytest.approx(0.50)

Expand Down
Loading