From a756ef7f466c5ad184824b487e7f44c4fddcfc8b Mon Sep 17 00:00:00 2001 From: Ryan Date: Mon, 10 Aug 2026 06:03:52 -0400 Subject: [PATCH 1/2] feat(proportion): arm muscle softs bicep scale+front past + triceps (0063) Post-pass bicep 0.78xUA with front Y past 0.010m and triceps 0.82xUA rear past (gated on profile bicep). Full-chain mid, pack fallback retune, triceps classifier unknown. Schema 1.4.0; MCP 46. --- src/meshops/proportion/blockout_recipe.py | 145 ++++++ .../torso_limb_f_athletic_v1.json | 6 +- .../torso_limb_m_athletic_v1.json | 6 +- src/meshops/proportion/constraints.py | 3 + tests/test_proportion_arm_muscle_softs.py | 475 ++++++++++++++++++ 5 files changed, 629 insertions(+), 6 deletions(-) create mode 100644 tests/test_proportion_arm_muscle_softs.py diff --git a/src/meshops/proportion/blockout_recipe.py b/src/meshops/proportion/blockout_recipe.py index fa4a87d..da5d090 100644 --- a/src/meshops/proportion/blockout_recipe.py +++ b/src/meshops/proportion/blockout_recipe.py @@ -93,6 +93,17 @@ 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 +# 0063 — arm muscle softs (bicep / triceps) +BICEP_ARM_RX_SCALE: Final[float] = 0.78 +BICEP_RY_FRAC: Final[float] = 0.90 +BICEP_RZ_FRAC: Final[float] = 0.95 +BICEP_FRONT_PAST_M: Final[float] = 0.010 +BICEP_ALONG_T: Final[float] = 0.50 +TRICEP_ARM_RX_SCALE: Final[float] = 0.82 +TRICEP_RY_FRAC: Final[float] = 0.88 +TRICEP_RZ_FRAC: Final[float] = 0.92 +TRICEP_REAR_PAST_M: Final[float] = 0.010 +TRICEP_ALONG_T: Final[float] = 0.50 # --- 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 @@ -1394,6 +1405,125 @@ def _shoulder_x_abs_for_girdle( return None +def _ua_shaft_metrics( + parts: list[RecipePart], + side: str, + *, + along_t: float, +) -> tuple[float, list[float]] | None: + """Return (ua_prox_r, mid_xyz) or None. + + B15: p0 = RECIPE_limb_upper_arm_{side}.p0 + p1 = RECIPE_arm_taper_dist_ua_{side}.p1 if present else limb.p1 + B17: mid = lerp(p0, p1, along_t); shaft_y for past math = mid[1] + ua_r = limb_upper_arm.radius_m (prox) + """ + limb = next((p for p in parts if p.name == f"RECIPE_limb_upper_arm_{side}"), None) + if limb is None or limb.p0 is None or limb.radius_m is None: + return None + try: + ua_r = float(limb.radius_m) + p0 = [float(limb.p0[0]), float(limb.p0[1]), float(limb.p0[2])] + except (TypeError, ValueError, IndexError): + return None + if not math.isfinite(ua_r) or ua_r <= 0.0: + return None + if not all(math.isfinite(v) for v in p0): + return None + + p1: list[float] | None = None + dist = next((p for p in parts if p.name == f"RECIPE_arm_taper_dist_ua_{side}"), None) + if dist is not None and dist.p1 is not None: + try: + cand = [float(dist.p1[0]), float(dist.p1[1]), float(dist.p1[2])] + except (TypeError, ValueError, IndexError): + cand = [] + if len(cand) == 3 and all(math.isfinite(v) for v in cand): + p1 = cand + if p1 is None: + if limb.p1 is None: + return None + try: + p1 = [float(limb.p1[0]), float(limb.p1[1]), float(limb.p1[2])] + except (TypeError, ValueError, IndexError): + return None + if not all(math.isfinite(v) for v in p1): + return None + + if _segment_length((p0[0], p0[1], p0[2]), (p1[0], p1[1], p1[2])) <= _NEAR_ZERO_LEN: + return None + + t = float(along_t) + mid = [ + p0[0] + t * (p1[0] - p0[0]), + p0[1] + t * (p1[1] - p0[1]), + p0[2] + t * (p1[2] - p0[2]), + ] + return ua_r, mid + + +def _apply_arm_muscle_softs(parts: list[RecipePart], messages: list[str]) -> None: + """0063: bicep scale+front past + triceps append (post-pass only — B18). + + After profile + 0060/0061; before breast hang. Requires measured UA (limbs). + Triceps gated on same-side bicep presence (B16). Full-chain mid (B15/B17). + """ + for side in ("l", "r"): + metrics = _ua_shaft_metrics(parts, side, along_t=BICEP_ALONG_T) + if metrics is None: + continue # B14 + ua_r, mid = metrics + shaft_y = float(mid[1]) # B17 + + # --- bicep (only if profile already emitted one) --- + bicep = _find_recipe_part(parts, role="bicep_soft", side=side) # AI2 P2-3 + if bicep is not None and bicep.center is not None: + rx = ua_r * BICEP_ARM_RX_SCALE + ry = rx * BICEP_RY_FRAC + rz = rx * BICEP_RZ_FRAC + shaft_front = shaft_y - ua_r + cy = shaft_front - BICEP_FRONT_PAST_M + ry + bicep.rx_m = rx + bicep.ry_m = ry + bicep.rz_m = rz + bicep.center = [float(mid[0]), cy, float(mid[2])] + past = shaft_front - (cy - ry) + messages.append(f"bicep_soft_{side}: rx={rx:.4f} front_past={past:.4f}") + + # --- triceps: B16 gate on bicep presence --- + if bicep is None: + continue + tname = f"RECIPE_triceps_soft_{side}" + if any(p.name == tname for p in parts): + continue + t_metrics = _ua_shaft_metrics(parts, side, along_t=TRICEP_ALONG_T) + if t_metrics is None: + continue + _, tmid = t_metrics + trx = ua_r * TRICEP_ARM_RX_SCALE + try_ = trx * TRICEP_RY_FRAC + trz = trx * TRICEP_RZ_FRAC + shaft_rear = float(tmid[1]) + ua_r + tcy = shaft_rear + TRICEP_REAR_PAST_M - try_ + # role=limb_segment: name-gate future limb_segment filters (P3-3) + parts.append( + RecipePart( + name=tname, + role="limb_segment", + kind="ellipsoid", + center=[float(tmid[0]), tcy, float(tmid[2])], + rx_m=trx, + ry_m=try_, + rz_m=trz, + placement="full3d", + label=tname, + notes="name-gate future role==limb_segment filters (0063 P3-3)", + ) + ) + rear_past = (tcy + try_) - shaft_rear + messages.append(f"triceps_soft_{side}: rx={trx:.4f} rear_past={rear_past:.4f}") + + def _apply_shoulder_girdle_softs( parts: list[RecipePart], report: ProportionReport, @@ -3748,6 +3878,9 @@ def build_blockout_recipe( # 0061: clavicle radius/shelf + trap floors/nape (after 0060 bury; before breast hang) _apply_shoulder_girdle_softs(parts, report, resolved, messages) + # 0063: bicep scale+front past + triceps append (after profile + 0060/0061; before breast hang) + _apply_arm_muscle_softs(parts, messages) + # 0049 B4: drop breast_soft center Z for readable hang (before 0033 tilt) _apply_breast_hang_z(parts, report, resolved, messages) @@ -5613,6 +5746,11 @@ def run_blockout_emit_setup( __all__ = [ "AXIS_NOTES", + "BICEP_ALONG_T", + "BICEP_ARM_RX_SCALE", + "BICEP_FRONT_PAST_M", + "BICEP_RY_FRAC", + "BICEP_RZ_FRAC", "BPY_BASENAME", "CALF_BELLY_LAT_FRAC", "CALF_BELLY_REAR_FRAC", @@ -5699,6 +5837,11 @@ def run_blockout_emit_setup( "TRAP_RZ_FLOOR_FRAC_H", "TRAP_Y_BACK_FRAC_RY", "TRAP_Y_NEAR_ZERO", + "TRICEP_ALONG_T", + "TRICEP_ARM_RX_SCALE", + "TRICEP_REAR_PAST_M", + "TRICEP_RY_FRAC", + "TRICEP_RZ_FRAC", "UA_DIST_SHAFT_SCALE", "UA_PROX_SHAFT_SCALE", "UA_SPLIT_T", @@ -5710,6 +5853,7 @@ def run_blockout_emit_setup( "RecipePart", "_append_all_hip_softs", "_append_elbow_softs", + "_apply_arm_muscle_softs", "_apply_deltoid_socket_bury", "_apply_glute_seat_mass", "_apply_join_ready_overlaps", @@ -5727,6 +5871,7 @@ def run_blockout_emit_setup( "_midpoint_of_joints", "_neck_upper_z", "_pelvis_ref_rear_y", + "_ua_shaft_metrics", "build_blockout_recipe", "emit_bpy_script", "load_blockout_recipe", diff --git a/src/meshops/proportion/body_profiles/torso_limb_f_athletic_v1.json b/src/meshops/proportion/body_profiles/torso_limb_f_athletic_v1.json index d03df3f..c18f4d0 100644 --- a/src/meshops/proportion/body_profiles/torso_limb_f_athletic_v1.json +++ b/src/meshops/proportion/body_profiles/torso_limb_f_athletic_v1.json @@ -69,9 +69,9 @@ "parent_joint_id": "shoulder_l", "parent_joint_fallback": ["elbow_l"], "scale": { - "rx_frac_h": 0.028, - "ry_frac_h": 0.026, - "rz_frac_h": 0.032, + "rx_frac_h": 0.020, + "ry_frac_h": 0.018, + "rz_frac_h": 0.019, "use_diameter": "upper_arm" }, "placement_rules": ["mid_bone", "dual_lr"] diff --git a/src/meshops/proportion/body_profiles/torso_limb_m_athletic_v1.json b/src/meshops/proportion/body_profiles/torso_limb_m_athletic_v1.json index 92d0046..1a7850e 100644 --- a/src/meshops/proportion/body_profiles/torso_limb_m_athletic_v1.json +++ b/src/meshops/proportion/body_profiles/torso_limb_m_athletic_v1.json @@ -69,9 +69,9 @@ "parent_joint_id": "shoulder_l", "parent_joint_fallback": ["elbow_l"], "scale": { - "rx_frac_h": 0.032, - "ry_frac_h": 0.030, - "rz_frac_h": 0.036, + "rx_frac_h": 0.022, + "ry_frac_h": 0.020, + "rz_frac_h": 0.021, "use_diameter": "upper_arm" }, "placement_rules": ["mid_bone", "dual_lr"] diff --git a/src/meshops/proportion/constraints.py b/src/meshops/proportion/constraints.py index 0ada0e5..9bfff56 100644 --- a/src/meshops/proportion/constraints.py +++ b/src/meshops/proportion/constraints.py @@ -296,6 +296,9 @@ def classify_part_name(name: str) -> tuple[ConstraintRole, Side]: return "unknown", side if "elbow_soft" in lower: return "unknown", side + # 0063 B5: triceps soft → unknown before upper_arm (C_no_dup safe with bicep). + if "triceps" in lower: + return "unknown", side if "thigh" in lower or "limb_thigh" in lower: return "thigh", side if "upper_arm" in lower or "limb_upper_arm" in lower: diff --git a/tests/test_proportion_arm_muscle_softs.py b/tests/test_proportion_arm_muscle_softs.py new file mode 100644 index 0000000..890adf8 --- /dev/null +++ b/tests/test_proportion_arm_muscle_softs.py @@ -0,0 +1,475 @@ +"""Track 0063 - Arm muscle softs (bicep scale+front past + triceps append) T0-T14.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from meshops.proportion.anatomy_profile import load_anatomy_profile +from meshops.proportion.blockout_recipe import ( + BICEP_ALONG_T, + BICEP_ARM_RX_SCALE, + BICEP_FRONT_PAST_M, + BICEP_RY_FRAC, + BICEP_RZ_FRAC, + DELT_ARM_RADIUS_SCALE, + ELBOW_SOFT_SCALE, + TRICEP_ALONG_T, + TRICEP_ARM_RX_SCALE, + TRICEP_REAR_PAST_M, + TRICEP_RY_FRAC, + TRICEP_RZ_FRAC, + UA_DIST_SHAFT_SCALE, + UA_PROX_SHAFT_SCALE, + BlockoutRecipePackage, + RecipePart, + _apply_arm_muscle_softs, + _ua_shaft_metrics, + build_blockout_recipe, +) +from meshops.proportion.constraints import classify_part_name, validate_constraints +from meshops.proportion.models import ( + DiameterMeasure, + LandmarkXYZ, + ProportionReport, + QualityFlags, +) + + +def _lm( + id_: str, + *, + x_m: float | None = None, + y_m: float | None = None, + z_m: float | None = None, +) -> LandmarkXYZ: + return LandmarkXYZ(id=id_, x_m=x_m, y_m=y_m, z_m=z_m) + + +def _diam( + band_id: str, + *, + half_width_m: float | None = 0.05, +) -> DiameterMeasure: + w = half_width_m * 2.0 if half_width_m is not None else 0.1 + return DiameterMeasure( + band_id=band_id, + view="front", + width_px=40.0, + width_eucl_px=40.0, + theta_deg=90.0, + width_frac=0.1, + width_m=w, + half_width_m=half_width_m, + mid_x_px=100.0, + mid_y_px=200.0, + ) + + +def _limb_mass_report( + *, + height_m: float = 1.72, + ua_hw: float = 0.0438, + fa_hw: float | None = None, + shoulder_y: float = 0.05, +) -> ProportionReport: + """Synthetic full-limb report for 0063 arm muscle softs tests.""" + fa = fa_hw if fa_hw is not None else 0.0350 + lms = { + "sole": _lm("sole", x_m=0.0, y_m=0.0, z_m=0.0), + "crotch": _lm("crotch", x_m=0.0, y_m=0.0, z_m=0.90), + "crotch_pubic": _lm("crotch_pubic", x_m=0.0, y_m=0.0, z_m=0.86), + "hip_l": _lm("hip_l", x_m=-0.12, y_m=0.0, z_m=0.95), + "hip_r": _lm("hip_r", x_m=0.12, y_m=0.0, z_m=0.95), + "shoulder_l": _lm("shoulder_l", x_m=-0.20, y_m=shoulder_y, z_m=1.38), + "shoulder_r": _lm("shoulder_r", x_m=0.20, y_m=shoulder_y, z_m=1.38), + "neck_base": _lm("neck_base", x_m=0.0, y_m=0.0, z_m=1.45), + "chin": _lm("chin", x_m=0.0, y_m=-0.02, z_m=1.50), + "head_top": _lm("head_top", x_m=0.0, y_m=0.0, z_m=height_m), + "cranial_vertex": _lm("cranial_vertex", x_m=0.0, y_m=-0.01, z_m=1.68), + "elbow_l": _lm("elbow_l", x_m=-0.25, y_m=shoulder_y, z_m=1.10), + "elbow_r": _lm("elbow_r", x_m=0.25, y_m=shoulder_y, z_m=1.10), + "wrist_l": _lm("wrist_l", x_m=-0.30, y_m=shoulder_y, z_m=0.90), + "wrist_r": _lm("wrist_r", x_m=0.30, y_m=shoulder_y, z_m=0.90), + "knee_l": _lm("knee_l", x_m=-0.12, y_m=0.04, z_m=0.50), + "knee_r": _lm("knee_r", x_m=0.12, y_m=0.04, z_m=0.50), + "ankle_l": _lm("ankle_l", x_m=-0.10, y_m=0.01, z_m=0.08), + "ankle_r": _lm("ankle_r", x_m=0.10, y_m=0.01, z_m=0.08), + "chest_mid": _lm("chest_mid", x_m=0.0, y_m=0.0, z_m=1.25), + "chest_front": _lm("chest_front", x_m=0.0, y_m=-0.05, z_m=1.25), + } + diams = [ + _diam("bust", half_width_m=0.16), + _diam("waist", half_width_m=0.13), + _diam("neck", half_width_m=0.05), + _diam("upper_arm_l", half_width_m=ua_hw), + _diam("upper_arm_r", half_width_m=ua_hw), + _diam("forearm_l", half_width_m=fa), + _diam("forearm_r", half_width_m=fa), + _diam("thigh_l", half_width_m=0.06), + _diam("thigh_r", half_width_m=0.06), + _diam("calf_l", half_width_m=0.05), + _diam("calf_r", half_width_m=0.05), + ] + return ProportionReport( + schema_version="1.0.0", + height_m=height_m, + landmarks_xyz=lms, + diameters=diams, + quality=QualityFlags(), + ) + + +def _part( + name: str, + *, + kind: str = "capsule", + role: str = "limb_segment", + center: list[float] | None = None, + rx_m: float | None = None, + ry_m: float | None = None, + rz_m: float | None = None, + radius_m: float | None = None, + p0: list[float] | None = None, + p1: list[float] | None = None, +) -> RecipePart: + kwargs: dict[str, Any] = { + "name": name, + "role": role, + "kind": kind, + "center": center, + "rx_m": rx_m, + "ry_m": ry_m, + "rz_m": rz_m, + "radius_m": radius_m, + "p0": p0, + "p1": p1, + } + clean = {k: v for k, v in kwargs.items() if v is not None or k in ("name", "role", "kind")} + return RecipePart.model_validate(clean) + + +def _build_limbs_profile( + *, + ua_hw: float = 0.0438, + fa_hw: float = 0.0350, + profile_id: str = "torso_limb_f_athletic_v1", +) -> Any: + report = _limb_mass_report(ua_hw=ua_hw, fa_hw=fa_hw) + profile = load_anatomy_profile(profile_id) + return build_blockout_recipe( + report, + limbs=True, + torso="ovals", + profile=profile, + ) + + +# --------------------------------------------------------------------------- +# T0-T14 +# --------------------------------------------------------------------------- + + +def test_t0_const_freezes() -> None: + """T0: B1-B8 const pins.""" + assert BICEP_ARM_RX_SCALE == 0.78 + assert BICEP_RY_FRAC == 0.90 + assert BICEP_RZ_FRAC == 0.95 + assert BICEP_FRONT_PAST_M == 0.010 + assert BICEP_ALONG_T == 0.50 + assert TRICEP_ARM_RX_SCALE == 0.82 + assert TRICEP_RY_FRAC == 0.88 + assert TRICEP_RZ_FRAC == 0.92 + assert TRICEP_REAR_PAST_M == 0.010 + assert TRICEP_ALONG_T == 0.50 + + +def test_t1_bicep_rx_not_055() -> None: + """T1: limbs+profile -> bicep rx == ua_r * 0.78; rejects 0.55x.""" + ua_hw = 0.0438 + pkg = _build_limbs_profile(ua_hw=ua_hw) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + ua = by[f"RECIPE_limb_upper_arm_{side}"] + bicep = by[f"RECIPE_bicep_soft_{side}"] + ua_r = float(ua.radius_m) # type: ignore[arg-type] + assert bicep.rx_m is not None + assert float(bicep.rx_m) == pytest.approx(ua_r * BICEP_ARM_RX_SCALE, abs=1e-6) + assert float(bicep.rx_m) != pytest.approx(ua_r * 0.55, abs=1e-4) + assert float(bicep.rx_m) > ua_r * 0.55 + 1e-4 + + +def test_t2_bicep_front_past() -> None: + """T2: front past >= BICEP_FRONT_PAST_M - eps.""" + pkg = _build_limbs_profile() + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + metrics = _ua_shaft_metrics(list(pkg.parts), side, along_t=BICEP_ALONG_T) + assert metrics is not None + ua_r, mid = metrics + bicep = by[f"RECIPE_bicep_soft_{side}"] + assert bicep.center is not None and bicep.ry_m is not None + shaft_front = float(mid[1]) - ua_r + cy = float(bicep.center[1]) + ry = float(bicep.ry_m) + past = shaft_front - (cy - ry) + assert past >= BICEP_FRONT_PAST_M - 1e-6 + + +def test_t3_triceps_present_when_profile() -> None: + """T3: RECIPE_triceps_soft_l/r present when profile+limbs.""" + pkg = _build_limbs_profile() + names = {p.name for p in pkg.parts} + assert "RECIPE_triceps_soft_l" in names + assert "RECIPE_triceps_soft_r" in names + for side in ("l", "r"): + tri = next(p for p in pkg.parts if p.name == f"RECIPE_triceps_soft_{side}") + assert tri.role == "limb_segment" + assert tri.kind == "ellipsoid" + assert tri.center is not None + assert tri.rx_m is not None + + +def test_t4_triceps_rear_past() -> None: + """T4: triceps rear past >= TRICEP_REAR_PAST_M - eps.""" + pkg = _build_limbs_profile() + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + metrics = _ua_shaft_metrics(list(pkg.parts), side, along_t=TRICEP_ALONG_T) + assert metrics is not None + ua_r, tmid = metrics + tri = by[f"RECIPE_triceps_soft_{side}"] + assert tri.center is not None and tri.ry_m is not None + shaft_rear = float(tmid[1]) + ua_r + tcy = float(tri.center[1]) + try_ = float(tri.ry_m) + rear_past = (tcy + try_) - shaft_rear + assert rear_past >= TRICEP_REAR_PAST_M - 1e-6 + + +def test_t5_c_no_dup_with_arms_elbow_bicep_triceps() -> None: + """T5: C_no_dup pass with split arms + elbow + bicep + triceps.""" + parts = [ + _part( + "RECIPE_limb_upper_arm_l", + radius_m=0.0438, + p0=[-0.20, 0.05, 1.38], + p1=[-0.225, 0.05, 1.24], + ), + _part( + "RECIPE_arm_taper_dist_ua_l", + radius_m=0.0385, + p0=[-0.225, 0.05, 1.24], + p1=[-0.25, 0.05, 1.10], + ), + _part( + "RECIPE_limb_forearm_l", + radius_m=0.0350, + p0=[-0.25, 0.05, 1.10], + p1=[-0.275, 0.05, 1.00], + ), + _part( + "RECIPE_arm_taper_dist_fa_l", + radius_m=0.0273, + p0=[-0.275, 0.05, 1.00], + p1=[-0.30, 0.05, 0.90], + ), + _part( + "RECIPE_elbow_soft_l", + kind="ellipsoid", + center=[-0.25, 0.05, 1.10], + rx_m=0.0424, + ry_m=0.0424, + rz_m=0.0424, + ), + _part( + "RECIPE_bicep_soft_l", + kind="ellipsoid", + role="bicep_soft", + center=[-0.225, -0.08, 1.24], + rx_m=0.0342, + ry_m=0.0308, + rz_m=0.0325, + ), + _part( + "RECIPE_triceps_soft_l", + kind="ellipsoid", + center=[-0.225, 0.12, 1.24], + rx_m=0.0359, + ry_m=0.0316, + rz_m=0.0330, + ), + ] + pkg = BlockoutRecipePackage(parts=parts, counts={"parts": len(parts)}) + result = validate_constraints(pkg) + by_id = {r.id: r for r in result.rules} + assert by_id["C_no_dup_limb"].status == "pass", by_id["C_no_dup_limb"].message + + +def test_t6_classifier_triceps_unknown_bicep_upper_arm() -> None: + """T6: classify triceps→unknown; bicep→upper_arm.""" + assert classify_part_name("RECIPE_triceps_soft_l") == ("unknown", "l") + assert classify_part_name("RECIPE_triceps_soft_r") == ("unknown", "r") + assert classify_part_name("RECIPE_bicep_soft_l") == ("upper_arm", "l") + assert classify_part_name("RECIPE_bicep_soft_r") == ("upper_arm", "r") + + +def test_t7_fence_0062_taper_elbow() -> None: + """T7: 0062 fence — UA dist/prox scales; elbow r > max adj.""" + ua_mid = 0.0438 + fa_mid = 0.0350 + height_m = 1.72 + report = _limb_mass_report(height_m=height_m, ua_hw=ua_mid, fa_hw=fa_mid) + pkg = build_blockout_recipe(report, limbs=True) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + ua = by[f"RECIPE_limb_upper_arm_{side}"] + ua_dist = by[f"RECIPE_arm_taper_dist_ua_{side}"] + elbow = by[f"RECIPE_elbow_soft_{side}"] + fa = by[f"RECIPE_limb_forearm_{side}"] + assert float(ua.radius_m) == pytest.approx( # type: ignore[arg-type] + ua_mid * UA_PROX_SHAFT_SCALE, abs=1e-9 + ) + assert float(ua_dist.radius_m) == pytest.approx( # type: ignore[arg-type] + ua_mid * UA_DIST_SHAFT_SCALE, abs=1e-9 + ) + adj = max(float(ua_dist.radius_m), float(fa.radius_m)) # type: ignore[arg-type] + assert float(elbow.rx_m) > adj # type: ignore[arg-type] + assert float(elbow.rx_m) == pytest.approx( # type: ignore[arg-type] + ELBOW_SOFT_SCALE * adj, abs=1e-6 + ) + + +def test_t8_fence_0060_delt_not_055() -> None: + """T8: 0060 fence - delt still DELT scale path (not x0.55).""" + arm_hw = 0.0438 + report = _limb_mass_report(ua_hw=arm_hw) + profile = load_anatomy_profile("torso_limb_f_athletic_v1") + pkg = build_blockout_recipe(report, limbs=True, profile=profile) + delts = [p for p in pkg.parts if p.role == "deltoid_soft"] + assert len(delts) == 2 + expected = arm_hw * DELT_ARM_RADIUS_SCALE + for d in delts: + assert d.rx_m is not None + assert float(d.rx_m) >= expected - 1e-6 + assert float(d.rx_m) != pytest.approx(arm_hw * 0.55, abs=1e-3) + + +def test_t9_messages_bicep_and_triceps() -> None: + """T9: messages contain bicep_soft + triceps_soft.""" + pkg = _build_limbs_profile() + assert any("bicep_soft_l:" in m for m in pkg.messages) + assert any("bicep_soft_r:" in m for m in pkg.messages) + assert any("triceps_soft_l:" in m for m in pkg.messages) + assert any("triceps_soft_r:" in m for m in pkg.messages) + assert any("front_past=" in m for m in pkg.messages) + assert any("rear_past=" in m for m in pkg.messages) + + +def test_t10_product_like_scales() -> None: + """T10: product-like ua_r=0.0438 → bicep≈0.0342, triceps≈0.0359.""" + ua_hw = 0.0438 + pkg = _build_limbs_profile(ua_hw=ua_hw) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + bicep = by[f"RECIPE_bicep_soft_{side}"] + tri = by[f"RECIPE_triceps_soft_{side}"] + assert float(bicep.rx_m) == pytest.approx(0.034164, abs=1e-4) # type: ignore[arg-type] + assert float(tri.rx_m) == pytest.approx(0.035916, abs=1e-4) # type: ignore[arg-type] + # Convenience pins from plan + assert float(bicep.rx_m) == pytest.approx(0.0342, abs=1e-3) # type: ignore[arg-type] + assert float(tri.rx_m) == pytest.approx(0.0359, abs=1e-3) # type: ignore[arg-type] + + +def test_t11_full_chain_mid_xz_and_y_past() -> None: + """T11: limbs+profile full-chain mid X/Z; Y from past law.""" + pkg = _build_limbs_profile() + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + metrics = _ua_shaft_metrics(list(pkg.parts), side, along_t=BICEP_ALONG_T) + assert metrics is not None + ua_r, mid = metrics + bicep = by[f"RECIPE_bicep_soft_{side}"] + assert bicep.center is not None and bicep.ry_m is not None + assert float(bicep.center[0]) == pytest.approx(float(mid[0]), abs=1e-6) + assert float(bicep.center[2]) == pytest.approx(float(mid[2]), abs=1e-6) + # Y is front-past law, not mid bone Y + shaft_front = float(mid[1]) - ua_r + expected_cy = shaft_front - BICEP_FRONT_PAST_M + float(bicep.ry_m) + assert float(bicep.center[1]) == pytest.approx(expected_cy, abs=1e-6) + assert float(bicep.center[1]) != pytest.approx(float(mid[1]), abs=1e-3) + + +def test_t12_no_ua_skips_softs() -> None: + """T12: no limb_upper_arm → no crash; softs skipped.""" + # Profile without limbs: bicep pea present; post-pass skips (no UA). + report = _limb_mass_report() + profile = load_anatomy_profile("torso_limb_f_athletic_v1") + pkg = build_blockout_recipe(report, limbs=False, profile=profile) + assert not any(p.name.startswith("RECIPE_triceps_soft_") for p in pkg.parts) + assert not any("bicep_soft_l: rx=" in m for m in pkg.messages) + assert not any("triceps_soft_" in m for m in pkg.messages) + + # Direct helper: empty parts is quiet + messages: list[str] = [] + parts: list[RecipePart] = [] + _apply_arm_muscle_softs(parts, messages) + assert parts == [] + assert messages == [] + assert _ua_shaft_metrics([], "l", along_t=0.5) is None + + +def test_t13_full_chain_mid_ne_prox_only() -> None: + """T13: full-chain mid ≠ prox-only mid when taper present (B15).""" + # Asymmetric UA: shoulder → elbow with large X/Z change; split at mid. + # Prox capsule ends at seam; full chain goes to elbow. + parts = [ + _part( + "RECIPE_limb_upper_arm_l", + radius_m=0.04, + p0=[-0.20, 0.05, 1.38], + p1=[-0.225, 0.05, 1.24], # seam @ t=0.5 of full chain + ), + _part( + "RECIPE_arm_taper_dist_ua_l", + radius_m=0.035, + p0=[-0.225, 0.05, 1.24], + p1=[-0.25, 0.05, 1.10], # elbow + ), + ] + metrics = _ua_shaft_metrics(parts, "l", along_t=0.50) + assert metrics is not None + _, full_mid = metrics + # Prox-only mid = lerp(prox.p0, prox.p1, 0.5) + prox = parts[0] + assert prox.p0 is not None and prox.p1 is not None + prox_mid = [ + float(prox.p0[0]) + 0.5 * (float(prox.p1[0]) - float(prox.p0[0])), + float(prox.p0[1]) + 0.5 * (float(prox.p1[1]) - float(prox.p0[1])), + float(prox.p0[2]) + 0.5 * (float(prox.p1[2]) - float(prox.p0[2])), + ] + # Full-chain mid at t=0.5 == seam (prox.p1); prox-only mid is t=0.25 class + assert full_mid[0] != pytest.approx(prox_mid[0], abs=1e-9) + assert full_mid[2] != pytest.approx(prox_mid[2], abs=1e-9) + # Full mid X/Z should match lerp(shoulder, elbow, 0.5) + shoulder = [-0.20, 0.05, 1.38] + elbow = [-0.25, 0.05, 1.10] + expected = [ + shoulder[0] + 0.5 * (elbow[0] - shoulder[0]), + shoulder[1] + 0.5 * (elbow[1] - shoulder[1]), + shoulder[2] + 0.5 * (elbow[2] - shoulder[2]), + ] + for i in range(3): + assert float(full_mid[i]) == pytest.approx(expected[i], abs=1e-9) + + +def test_t14_no_profile_no_triceps() -> None: + """T14: limbs=True, no profile → no RECIPE_triceps_soft_* (B16).""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True, torso="ovals") + assert not any(p.name.startswith("RECIPE_triceps_soft_") for p in pkg.parts) + assert not any(p.role == "bicep_soft" for p in pkg.parts) + assert not any("triceps_soft_" in m for m in pkg.messages) + # UA still present + assert any(p.name.startswith("RECIPE_limb_upper_arm_") for p in pkg.parts) From 3c2fc8cd4cb64e38ce3d5ad99f0817bee7b08ff2 Mon Sep 17 00:00:00 2001 From: Ryan Date: Mon, 10 Aug 2026 06:11:15 -0400 Subject: [PATCH 2/2] test(proportion): B17 differential Y covers shaft_y=mid not p0 (0063) Codex R1 P3: equal shoulder/elbow Y fixtures could not fail a mistaken shaft_y=p0[1]. Add T13b with differential chain Y. --- tests/test_proportion_arm_muscle_softs.py | 59 +++++++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/tests/test_proportion_arm_muscle_softs.py b/tests/test_proportion_arm_muscle_softs.py index 890adf8..98a5059 100644 --- a/tests/test_proportion_arm_muscle_softs.py +++ b/tests/test_proportion_arm_muscle_softs.py @@ -73,9 +73,11 @@ def _limb_mass_report( ua_hw: float = 0.0438, fa_hw: float | None = None, shoulder_y: float = 0.05, + elbow_y: float | None = None, ) -> ProportionReport: """Synthetic full-limb report for 0063 arm muscle softs tests.""" fa = fa_hw if fa_hw is not None else 0.0350 + ey = float(elbow_y) if elbow_y is not None else float(shoulder_y) lms = { "sole": _lm("sole", x_m=0.0, y_m=0.0, z_m=0.0), "crotch": _lm("crotch", x_m=0.0, y_m=0.0, z_m=0.90), @@ -88,10 +90,10 @@ def _limb_mass_report( "chin": _lm("chin", x_m=0.0, y_m=-0.02, z_m=1.50), "head_top": _lm("head_top", x_m=0.0, y_m=0.0, z_m=height_m), "cranial_vertex": _lm("cranial_vertex", x_m=0.0, y_m=-0.01, z_m=1.68), - "elbow_l": _lm("elbow_l", x_m=-0.25, y_m=shoulder_y, z_m=1.10), - "elbow_r": _lm("elbow_r", x_m=0.25, y_m=shoulder_y, z_m=1.10), - "wrist_l": _lm("wrist_l", x_m=-0.30, y_m=shoulder_y, z_m=0.90), - "wrist_r": _lm("wrist_r", x_m=0.30, y_m=shoulder_y, z_m=0.90), + "elbow_l": _lm("elbow_l", x_m=-0.25, y_m=ey, z_m=1.10), + "elbow_r": _lm("elbow_r", x_m=0.25, y_m=ey, z_m=1.10), + "wrist_l": _lm("wrist_l", x_m=-0.30, y_m=ey, z_m=0.90), + "wrist_r": _lm("wrist_r", x_m=0.30, y_m=ey, z_m=0.90), "knee_l": _lm("knee_l", x_m=-0.12, y_m=0.04, z_m=0.50), "knee_r": _lm("knee_r", x_m=0.12, y_m=0.04, z_m=0.50), "ankle_l": _lm("ankle_l", x_m=-0.10, y_m=0.01, z_m=0.08), @@ -420,6 +422,55 @@ def test_t12_no_ua_skips_softs() -> None: assert _ua_shaft_metrics([], "l", along_t=0.5) is None +def test_t13b_shaft_y_uses_mid_not_p0() -> None: + """B17: shaft_y = mid[1] on full chain — not shoulder-only p0[1]. + + Codex R1 P3: default fixtures used equal shoulder/elbow Y so a mistaken + shaft_y=p0[1] still passed. Differential Y fails that bug. + """ + shoulder_y = 0.05 + elbow_y = 0.17 + parts = [ + _part( + "RECIPE_limb_upper_arm_l", + radius_m=0.04, + p0=[-0.20, shoulder_y, 1.38], + p1=[-0.225, 0.11, 1.24], # seam + ), + _part( + "RECIPE_arm_taper_dist_ua_l", + radius_m=0.035, + p0=[-0.225, 0.11, 1.24], + p1=[-0.25, elbow_y, 1.10], # elbow + ), + _part( + "RECIPE_bicep_soft_l", + kind="ellipsoid", + role="bicep_soft", + center=[-0.225, 0.05, 1.24], + rx_m=0.02, + ry_m=0.02, + rz_m=0.02, + ), + ] + messages: list[str] = [] + _apply_arm_muscle_softs(parts, messages) + metrics = _ua_shaft_metrics(parts, "l", along_t=BICEP_ALONG_T) + assert metrics is not None + ua_r, mid = metrics + # mid Y must be lerp of full chain (not p0 Y alone) + assert float(mid[1]) == pytest.approx((shoulder_y + elbow_y) / 2.0, abs=1e-9) + assert float(mid[1]) != pytest.approx(shoulder_y, abs=1e-6) + bicep = next(p for p in parts if p.name == "RECIPE_bicep_soft_l") + assert bicep.center is not None and bicep.ry_m is not None + shaft_front = float(mid[1]) - ua_r + expected_cy = shaft_front - BICEP_FRONT_PAST_M + float(bicep.ry_m) + assert float(bicep.center[1]) == pytest.approx(expected_cy, abs=1e-9) + # Wrong shaft_y=p0[1] would place cy at shoulder-based front past + wrong_cy = (shoulder_y - ua_r) - BICEP_FRONT_PAST_M + float(bicep.ry_m) + assert float(bicep.center[1]) != pytest.approx(wrong_cy, abs=1e-6) + + def test_t13_full_chain_mid_ne_prox_only() -> None: """T13: full-chain mid ≠ prox-only mid when taper present (B15).""" # Asymmetric UA: shoulder → elbow with large X/Z change; split at mid.