From e067896e1d05592761709cd738d03c6ae7664815 Mon Sep 17 00:00:00 2001 From: Ryan Date: Sun, 9 Aug 2026 19:50:38 -0400 Subject: [PATCH] feat(proportion): 0071 knee seam widen + calf p0 belly bias Knee soft scales from seam adj (taper_dist else thigh, calf_a) at 1.10x with anisotropic axes (ry 0.90, rz 0.75) and mild outer/rear center bias. Calf belly offsets cyl.p0 only (lat 0.22, rear 0.28); keep a/b on joints and B6 p1 Y sync. Emit knee softs before adduction. Pin retarget t5/t7; new T0-T12 suite. --- src/meshops/proportion/blockout_recipe.py | 99 ++++- tests/test_proportion_blockout_recipe.py | 46 ++- tests/test_proportion_knee_calf_shape.py | 402 +++++++++++++++++++++ tests/test_proportion_thigh_shaft_taper.py | 25 +- 4 files changed, 535 insertions(+), 37 deletions(-) create mode 100644 tests/test_proportion_knee_calf_shape.py diff --git a/src/meshops/proportion/blockout_recipe.py b/src/meshops/proportion/blockout_recipe.py index 6040285..f6f9da5 100644 --- a/src/meshops/proportion/blockout_recipe.py +++ b/src/meshops/proportion/blockout_recipe.py @@ -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 @@ -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 @@ -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, @@ -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, @@ -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 @@ -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] = [] @@ -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, @@ -2276,19 +2323,29 @@ 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, @@ -2296,15 +2353,15 @@ def _append_knee_softs( 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( @@ -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", @@ -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", @@ -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", diff --git a/tests/test_proportion_blockout_recipe.py b/tests/test_proportion_blockout_recipe.py index 1025605..b0542b0 100644 --- a/tests/test_proportion_blockout_recipe.py +++ b/tests/test_proportion_blockout_recipe.py @@ -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, @@ -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 @@ -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" @@ -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, @@ -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) diff --git a/tests/test_proportion_knee_calf_shape.py b/tests/test_proportion_knee_calf_shape.py new file mode 100644 index 0000000..a071b8d --- /dev/null +++ b/tests/test_proportion_knee_calf_shape.py @@ -0,0 +1,402 @@ +"""Track 0071 - Knee widen (seam) + calf belly p0-only bias (T0-T12).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +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, + 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, + THIGH_DIST_SHAFT_SCALE, + _knee_seam_radius_m, + build_blockout_recipe, +) +from meshops.proportion.body_template import AppliedConstants, TemplateAppliedPackage +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, + thigh_hw: float = 0.06, + calf_hw: float = 0.05, + arm_hw: float = 0.04, + include_knees: bool = True, + knee_y: float | None = 0.04, + include_feet_lms: bool = False, +) -> ProportionReport: + """Synthetic full-limb report for 0071 knee/calf shape tests.""" + lms: dict[str, LandmarkXYZ] = { + "crotch": _lm("crotch", x_m=0.0, y_m=0.0, z_m=0.90), + "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=0.0, z_m=1.38), + "shoulder_r": _lm("shoulder_r", x_m=0.20, y_m=0.0, z_m=1.38), + "neck_base": _lm("neck_base", x_m=0.0, y_m=0.0, z_m=1.45), + "head_top": _lm("head_top", x_m=0.0, y_m=0.0, z_m=1.72), + "elbow_l": _lm("elbow_l", x_m=-0.25, y_m=0.0, z_m=1.10), + "elbow_r": _lm("elbow_r", x_m=0.25, y_m=0.0, z_m=1.10), + "wrist_l": _lm("wrist_l", x_m=-0.30, y_m=0.0, z_m=0.90), + "wrist_r": _lm("wrist_r", x_m=0.30, y_m=0.0, z_m=0.90), + "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), + } + if include_knees: + lms["knee_l"] = _lm("knee_l", x_m=-0.12, y_m=knee_y, z_m=0.50) + lms["knee_r"] = _lm("knee_r", x_m=0.12, y_m=knee_y, z_m=0.50) + if include_feet_lms: + lms["heel_l"] = _lm("heel_l", x_m=-0.10, y_m=0.06, z_m=0.02) + lms["heel_r"] = _lm("heel_r", x_m=0.10, y_m=0.06, z_m=0.02) + lms["toe_l"] = _lm("toe_l", x_m=-0.10, y_m=-0.12, z_m=0.02) + lms["toe_r"] = _lm("toe_r", x_m=0.10, y_m=-0.12, z_m=0.02) + 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=arm_hw), + _diam("upper_arm_r", half_width_m=arm_hw), + _diam("forearm_l", half_width_m=arm_hw), + _diam("forearm_r", half_width_m=arm_hw), + _diam("thigh_l", half_width_m=thigh_hw), + _diam("thigh_r", half_width_m=thigh_hw), + _diam("calf_l", half_width_m=calf_hw), + _diam("calf_r", half_width_m=calf_hw), + ] + if include_feet_lms: + diams.append(_diam("ank_foot_l", half_width_m=0.035)) + diams.append(_diam("ank_foot_r", half_width_m=0.035)) + return ProportionReport( + schema_version="1.0.0", + height_m=height_m, + landmarks_xyz=lms, + diameters=diams, + quality=QualityFlags(), + ) + + +def _template_applied_tilt(*, thigh_tilt_deg: float = 10.0, height_m: float = 1.72) -> object: + return TemplateAppliedPackage( + template_id="female_adult_athletic", + sex="female", + archetype="adult_athletic", + source_report="test", + height_m=height_m, + constants=AppliedConstants( + breast_mode="dual_tilted", + glute_mode_default="oval", + torso_mode_default="trap", + thigh_tilt_deg=thigh_tilt_deg, + ), + ) + + +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, +) -> Any: + from meshops.proportion.blockout_recipe import 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) + + +# --------------------------------------------------------------------------- +# T0-T12 +# --------------------------------------------------------------------------- + + +def test_t0_const_freezes() -> None: + """T0: knee seam freezes + calf belly lat/rear + 0045 scale fence.""" + assert KNEE_SOFT_FRAC == 1.10 + assert KNEE_SOFT_MIN_FRAC_H == 0.018 + assert KNEE_SOFT_RY_FRAC == 0.90 + assert KNEE_SOFT_RZ_FRAC == 0.75 + assert KNEE_SOFT_OUTER_FRAC_RX == 0.06 + assert KNEE_SOFT_REAR_FRAC_RY == 0.10 + assert CALF_BELLY_LAT_FRAC == 0.22 + assert CALF_BELLY_REAR_FRAC == 0.28 + assert CALF_BELLY_SCALE == 1.08 + assert CALF_PROX_END_SCALE == 0.88 + assert CALF_DIST_END_SCALE == 0.72 + + +def test_t1_knee_rx_above_calf_a_near_seam() -> None: + """T1: knee rx > calf_a.rx and rx >= 0.95*seam; not require rx >= thigh prox.""" + height_m = 1.72 + thigh_hw = 0.08 + calf_hw = 0.04 + report = _limb_mass_report(height_m=height_m, thigh_hw=thigh_hw, calf_hw=calf_hw) + pkg = build_blockout_recipe(report, limbs=True) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + knee = by[f"RECIPE_knee_soft_{side}"] + thigh = by[f"RECIPE_limb_thigh_{side}"] + dist = by[f"RECIPE_thigh_taper_dist_{side}"] + calf_a = by[f"RECIPE_calf_a_{side}"] + assert knee.rx_m is not None and calf_a.rx_m is not None + assert dist.radius_m is not None and thigh.radius_m is not None + seam = max(float(dist.radius_m), float(calf_a.rx_m)) + assert float(knee.rx_m) > float(calf_a.rx_m) + assert float(knee.rx_m) >= 0.95 * seam - 1e-9 + # Not leg-wide: rx may be < thigh prox + assert float(thigh.radius_m) > float(calf_a.rx_m) + + +def test_t2_knee_anisotropy() -> None: + """T2: rx >= ry >= rz with 0.90 / 0.75 fracs.""" + report = _limb_mass_report(thigh_hw=0.08, calf_hw=0.04) + pkg = build_blockout_recipe(report, limbs=True) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + knee = by[f"RECIPE_knee_soft_{side}"] + assert knee.rx_m is not None and knee.ry_m is not None and knee.rz_m is not None + rx, ry, rz = float(knee.rx_m), float(knee.ry_m), float(knee.rz_m) + assert rx >= ry - 1e-12 + assert ry >= rz - 1e-12 + assert ry == pytest.approx(rx * KNEE_SOFT_RY_FRAC, abs=1e-9) + assert rz == pytest.approx(rx * KNEE_SOFT_RZ_FRAC, abs=1e-9) + + +def test_t3_knee_bias_vs_co_moved_calf_a() -> None: + """T3: post-adduction outer/rear bias vs co-moved calf_a (not raw landmark).""" + report = _limb_mass_report(thigh_hw=0.08, calf_hw=0.04) + tpl = _template_applied_tilt(thigh_tilt_deg=10.0) + pkg = build_blockout_recipe(report, limbs=True, template_applied=tpl) # type: ignore[arg-type] + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + knee = by[f"RECIPE_knee_soft_{side}"] + calf_a = by[f"RECIPE_calf_a_{side}"] + assert knee.center is not None and calf_a.center is not None + sign = 1.0 if side == "r" else -1.0 + # Outer: knee further out than co-moved calf_a + assert sign * (float(knee.center[0]) - float(calf_a.center[0])) > 0 + # Rear: knee +Y vs calf_a + assert float(knee.center[1]) > float(calf_a.center[1]) + + +def test_t4_knee_message_rx() -> None: + """T4: message knee_soft_{side}: rx= present.""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True) + for side in ("l", "r"): + assert any(f"knee_soft_{side}: rx=" in m for m in pkg.messages) + assert not any( + m.startswith(f"knee_soft_{side}: r=") and "rx=" not in m for m in pkg.messages + ) + + +def test_t5_seam_adj_axes_message() -> None: + """T5: seam adj x 1.10 + axes + message rx= (pin spirit).""" + height_m = 1.72 + thigh_hw = 0.08 + calf_hw = 0.04 + report = _limb_mass_report(height_m=height_m, thigh_hw=thigh_hw, calf_hw=calf_hw) + pkg = build_blockout_recipe(report, limbs=True) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + knee = by[f"RECIPE_knee_soft_{side}"] + dist = by[f"RECIPE_thigh_taper_dist_{side}"] + calf_a = by[f"RECIPE_calf_a_{side}"] + 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 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) + helper = _knee_seam_radius_m(pkg.parts, side, report) + assert helper == pytest.approx(seam, abs=1e-9) + assert float(dist.radius_m) == pytest.approx( # type: ignore[arg-type] + thigh_hw * THIGH_DIST_SHAFT_SCALE, abs=1e-9 + ) + assert any(f"knee_soft_{side}: rx=" in m for m in pkg.messages) + + +def test_t5b_no_knee_skips_knee_soft() -> None: + """T5b: no knee joint → no knee_soft.""" + report = _limb_mass_report(include_knees=False) + pkg = build_blockout_recipe(report, limbs=True) + assert not any("knee_soft" in p.name for p in pkg.parts) + + +def test_t6_calf_split_names() -> None: + """T6: calf_a/cyl/b present; no limb_calf.""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + assert f"RECIPE_calf_a_{side}" in by + assert f"RECIPE_calf_cyl_{side}" in by + assert f"RECIPE_calf_b_{side}" in by + assert f"RECIPE_limb_calf_{side}" not in by + assert not any("limb_calf" in p.name.lower() for p in pkg.parts) + + +def test_t7_calf_p0_belly_bias_post_b6() -> None: + """T7: post-B6 (+feet): cyl.p0 lat+rear vs calf_a; p1 no rear belly offset.""" + report = _limb_mass_report(include_feet_lms=True, calf_hw=0.05, knee_y=0.04) + pkg = build_blockout_recipe(report, limbs=True, feet=True) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + a = by[f"RECIPE_calf_a_{side}"] + cyl = by[f"RECIPE_calf_cyl_{side}"] + assert a.center is not None and cyl.p0 is not None and cyl.p1 is not None + assert cyl.radius_m is not None + sign = 1.0 if side == "r" else -1.0 + cyl_r = float(cyl.radius_m) + dx = sign * CALF_BELLY_LAT_FRAC * cyl_r + dy = CALF_BELLY_REAR_FRAC * cyl_r + # p0 biased vs a.center (a on joint axis) + assert float(cyl.p0[0]) == pytest.approx(float(a.center[0]) + dx, abs=1e-6) + assert float(cyl.p0[1]) == pytest.approx(float(a.center[1]) + dy, abs=1e-6) + assert float(cyl.p0[2]) == pytest.approx(float(a.center[2]), abs=1e-6) + # p1: B6 may set Y to ank_foot; no lat/rear belly bias on distal end + b = by[f"RECIPE_calf_b_{side}"] + assert b.center is not None + assert float(cyl.p1[0]) == pytest.approx(float(b.center[0]), abs=1e-6) + assert float(cyl.p1[1]) == pytest.approx(float(b.center[1]), abs=1e-6) + # Distal Y is not proximal+rear (top-heavy gastroc only on p0) + assert abs(float(cyl.p1[1]) - (float(a.center[1]) + dy)) > 1e-3 + assert any(f"calf_{side}: belly bias p0 lat=" in m for m in pkg.messages) + + +def test_t8_calf_ab_centers_not_biased() -> None: + """T8: calf_a / calf_b centers stay on joint axis (not cyl belly offset).""" + report = _limb_mass_report(calf_hw=0.05, knee_y=0.04) + pkg = build_blockout_recipe(report, limbs=True) + by = {p.name: p for p in pkg.parts} + lms = report.landmarks_xyz + for side in ("l", "r"): + a = by[f"RECIPE_calf_a_{side}"] + b = by[f"RECIPE_calf_b_{side}"] + cyl = by[f"RECIPE_calf_cyl_{side}"] + knee = lms[f"knee_{side}"] + ankle = lms[f"ankle_{side}"] + assert a.center is not None and b.center is not None + assert cyl.p0 is not None + # a near knee joint XZ/Y + assert float(a.center[0]) == pytest.approx(float(knee.x_m), abs=1e-6) # type: ignore[arg-type] + assert float(a.center[2]) == pytest.approx(float(knee.z_m), abs=1e-6) # type: ignore[arg-type] + if knee.y_m is not None: + assert float(a.center[1]) == pytest.approx(float(knee.y_m), abs=1e-6) + # b near ankle + assert float(b.center[0]) == pytest.approx(float(ankle.x_m), abs=1e-6) # type: ignore[arg-type] + assert float(b.center[2]) == pytest.approx(float(ankle.z_m), abs=1e-6) # type: ignore[arg-type] + # a is not co-located with biased p0 in X/Y + assert abs(float(a.center[0]) - float(cyl.p0[0])) > 1e-4 + assert abs(float(a.center[1]) - float(cyl.p0[1])) > 1e-4 + + +def test_t9_calf_radius_order() -> None: + """T9: b.rx < cyl.r and a.rx <= cyl.r.""" + report = _limb_mass_report(calf_hw=0.05) + pkg = build_blockout_recipe(report, limbs=True) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + a = by[f"RECIPE_calf_a_{side}"] + b = by[f"RECIPE_calf_b_{side}"] + cyl = by[f"RECIPE_calf_cyl_{side}"] + assert a.rx_m is not None and b.rx_m is not None and cyl.radius_m is not None + assert float(b.rx_m) < float(cyl.radius_m) + assert float(a.rx_m) <= float(cyl.radius_m) + 1e-12 + + +def test_t10_join_ready_preserves_calf_order() -> None: + """T10: post join_ready, b.rx < cyl.r order preserved.""" + report = _limb_mass_report(include_feet_lms=True, calf_hw=0.05) + pkg = build_blockout_recipe(report, limbs=True, feet=True, join_ready=True) + by = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + b = by[f"RECIPE_calf_b_{side}"] + cyl = by[f"RECIPE_calf_cyl_{side}"] + assert b.rx_m is not None and cyl.radius_m is not None + assert float(b.rx_m) < float(cyl.radius_m) + + +def test_t11_calf_slant_pass() -> None: + """T11: C_calf_slant pass via validate_constraints on product-like limbs+feet.""" + report = _limb_mass_report(include_feet_lms=True, calf_hw=0.05, knee_y=0.04) + pkg = build_blockout_recipe(report, limbs=True, feet=True) + result = validate_constraints(pkg) + by_id = {r.id: r for r in result.rules} + assert by_id["C_calf_slant"].status == "pass", by_id["C_calf_slant"].message + + +def test_t12_classifier_and_no_dup() -> None: + """T12: classify knee_soft → unknown; C_no_dup_limb pass on product package.""" + assert classify_part_name("RECIPE_knee_soft_l") == ("unknown", "l") + assert classify_part_name("RECIPE_knee_soft_r") == ("unknown", "r") + report = _limb_mass_report(include_feet_lms=True) + pkg = build_blockout_recipe(report, limbs=True, feet=True) + 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 diff --git a/tests/test_proportion_thigh_shaft_taper.py b/tests/test_proportion_thigh_shaft_taper.py index c1b3dca..ea6cf3a 100644 --- a/tests/test_proportion_thigh_shaft_taper.py +++ b/tests/test_proportion_thigh_shaft_taper.py @@ -377,8 +377,12 @@ def test_t6_adduction_chain_knee_delta() -> None: def test_t7_knee_soft_max_path() -> None: - """T7: knee_soft r uses max(prox, dist); with prox scale 1.0 same as pre-taper mid.""" - from meshops.proportion.blockout_recipe import KNEE_SOFT_FRAC, KNEE_SOFT_MIN_FRAC_H + """T7 (0071): knee_soft scale uses SEAM adj, not full-leg max(prox, dist, calf_a).""" + from meshops.proportion.blockout_recipe import ( + KNEE_SOFT_FRAC, + KNEE_SOFT_MIN_FRAC_H, + _knee_seam_radius_m, + ) height_m = 1.72 thigh_hw = 0.08 @@ -391,20 +395,25 @@ def test_t7_knee_soft_max_path() -> None: prox = 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}"] - adj = max( + # Full-leg fence helper still includes prox + full_adj = max( float(prox.radius_m), # type: ignore[arg-type] float(dist.radius_m), # type: ignore[arg-type] float(calf_a.rx_m), # type: ignore[arg-type] ) - # prox == mid, dist < prox → max = prox (= mid) + # Seam: prefer taper_dist; max(dist, calf_a) — typically dist when prox=0.08 + seam = max(float(dist.radius_m), float(calf_a.rx_m)) # type: ignore[arg-type] assert float(prox.radius_m) == pytest.approx(thigh_hw, abs=1e-9) # type: ignore[arg-type] assert float(dist.radius_m) < float(prox.radius_m) # type: ignore[arg-type] - assert adj == pytest.approx(float(prox.radius_m), abs=1e-9) # type: ignore[arg-type] - expected = max(KNEE_SOFT_FRAC * adj, KNEE_SOFT_MIN_FRAC_H * height_m) + assert full_adj == pytest.approx(float(prox.radius_m), abs=1e-9) # type: ignore[arg-type] + assert seam == pytest.approx(float(dist.radius_m), abs=1e-9) # type: ignore[arg-type] + expected = max(KNEE_SOFT_FRAC * seam, KNEE_SOFT_MIN_FRAC_H * height_m) assert knee.rx_m == pytest.approx(expected, abs=1e-9) - # Helper path + # Helpers: full max for fence; seam for scale path helper_adj = _knee_adj_radius_m(pkg.parts, side, report) - assert helper_adj == pytest.approx(adj, abs=1e-9) + assert helper_adj == pytest.approx(full_adj, abs=1e-9) + helper_seam = _knee_seam_radius_m(pkg.parts, side, report) + assert helper_seam == pytest.approx(seam, abs=1e-9) def test_t8_hip_pair_child_is_limb_thigh() -> None: