From 9853985abb18470b039b67db7519e310e01468de Mon Sep 17 00:00:00 2001 From: Ryan Date: Sun, 9 Aug 2026 18:30:08 -0400 Subject: [PATCH] feat(proportion): anisotropic hip_soft trochanter mass (0069) Replace isotropic prox_soft sphere-on-joint with one anisotropic RECIPE_hip_soft per side at the hip joint (past thigh cap), de-sphering Michelin bead clutter without bridge-outer bury or glute rx inflate. --- src/meshops/proportion/blockout_recipe.py | 101 +++- src/meshops/proportion/connection_metrics.py | 2 + src/meshops/proportion/constraints.py | 3 + tests/test_proportion_blockout_recipe.py | 57 ++- tests/test_proportion_constraints.py | 19 +- tests/test_proportion_hip_soft_cluster.py | 502 +++++++++++++++++++ tests/test_proportion_thigh_shaft_taper.py | 9 +- 7 files changed, 651 insertions(+), 42 deletions(-) create mode 100644 tests/test_proportion_hip_soft_cluster.py diff --git a/src/meshops/proportion/blockout_recipe.py b/src/meshops/proportion/blockout_recipe.py index 5c3b5ff..6040285 100644 --- a/src/meshops/proportion/blockout_recipe.py +++ b/src/meshops/proportion/blockout_recipe.py @@ -99,8 +99,17 @@ DELT_RZ_FRAC: Final[float] = 0.85 DELT_OUTER_X_FRAC: Final[float] = 0.25 # * rx, sign by side (r:+, l:-); skip if crosses midline # 0046 B6: thigh proximal soft at hip (no dist_soft - 0045 B13). +# 0069: THIGH_PROX_SOFT_SCALE kept for fence/import smoke; superseded for product emit +# (isotropic RECIPE_prox_soft_thigh_* replaced by anisotropic RECIPE_hip_soft_*). THIGH_PROX_SOFT_SCALE: Final[float] = 1.18 -_THIGH_PROX_R_FLOOR: Final[float] = 1e-4 +# 0069 B1-B6: anisotropic hip soft at joint (replaces prox_soft sphere emit) +HIP_SOFT_RX_SCALE: Final[float] = 1.15 +HIP_SOFT_RY_FRAC_RX: Final[float] = 0.88 +HIP_SOFT_RZ_FRAC_RX: Final[float] = 0.70 +HIP_SOFT_CENTER: Final[str] = "hip_joint" +HIP_SOFT_Z_DROP_FRAC_H: Final[float] = 0.010 +HIP_SOFT_Y_REAR_FRAC_RX: Final[float] = 0.12 +_HIP_SOFT_R_FLOOR: Final[float] = 1e-4 # 0070 B1-B3: thigh shaft prox > distal taper (two capsules; no dual-radius schema). THIGH_PROX_SHAFT_SCALE: Final[float] = 1.00 # B1: prox segment r vs measured mid half-width THIGH_DIST_SHAFT_SCALE: Final[float] = 0.80 # B2: dist segment r vs mid; must be < B1 @@ -2124,6 +2133,7 @@ def _build_limbs( ) continue # 0070 B4: thigh → prox limb_thigh + dist thigh_taper_dist (not single tube). + # 0069 B8: no prox_soft sphere emit; hip soft post-pass after adduction. if band_id in ("thigh_l", "thigh_r"): side = "l" if band_id.endswith("_l") else "r" parts.extend( @@ -2136,23 +2146,6 @@ def _build_limbs( messages=messages, ) ) - # 0046 B6: prox soft at hip only vs measured mid_r (B6); no dist_soft (B7). - soft_r = max(float(radius) * THIGH_PROX_SOFT_SCALE, _THIGH_PROX_R_FLOOR) - soft_name = f"RECIPE_prox_soft_thigh_{side}" - parts.append( - RecipePart( - name=soft_name, - role="limb_segment", - kind="ellipsoid", - center=[float(p0[0]), float(p0[1]), float(p0[2])], - rx_m=soft_r, - ry_m=soft_r, - rz_m=soft_r, - placement=placement, - label=soft_name, - ) - ) - messages.append(f"thigh_{side}: prox_soft r={soft_r:.4f}") continue # 0062 B4/B7/B16: arm → prox limb + dist arm_taper (not single tube). if band_id in ("upper_arm_l", "upper_arm_r", "forearm_l", "forearm_r"): @@ -2314,6 +2307,64 @@ def _append_knee_softs( messages.append(f"knee_soft_{side}: r={r:.4f}") +def _append_all_hip_softs( + parts: list[RecipePart], + *, + height_m: float | None, + messages: list[str], +) -> None: + """0069: one anisotropic trochanter soft per side at hip joint; past thigh cap.""" + for side in ("l", "r"): + thigh = next( + (p for p in parts if p.name == f"RECIPE_limb_thigh_{side}"), + None, + ) + if thigh is None or thigh.p0 is None or thigh.radius_m is None: + messages.append(f"hip_soft_{side}: skipped (no limb_thigh p0/r)") + continue + hip = [float(thigh.p0[0]), float(thigh.p0[1]), float(thigh.p0[2])] + mid_r = float(thigh.radius_m) + rx = max(mid_r * HIP_SOFT_RX_SCALE, _HIP_SOFT_R_FLOOR) + ry = max(rx * HIP_SOFT_RY_FRAC_RX, _HIP_SOFT_R_FLOOR) + rz = max(rx * HIP_SOFT_RZ_FRAC_RX, _HIP_SOFT_R_FLOOR) + sign = 1.0 if side == "r" else -1.0 + + # B4: center at hip joint (p0 X/Z); Y = p0 y + mild rear (AI1 P3-6) + cx = float(hip[0]) + cy = float(hip[1]) + HIP_SOFT_Y_REAR_FRAC_RX * rx + if height_m is not None: + cz = float(hip[2]) - HIP_SOFT_Z_DROP_FRAC_H * float(height_m) + else: + cz = float(hip[2]) + + outer = cx + sign * rx + thigh_cap_outer = float(hip[0]) + sign * mid_r + # Visibility is a unit lock (T3), not a silent grow here + + name = f"RECIPE_hip_soft_{side}" + # Placement: follow limb placement spirit — if p0 y ~0 and no rear, front_plane OK + placement: Literal["full3d", "front_plane"] = "front_plane" if abs(cy) < 1e-6 else "full3d" + parts.append( + RecipePart( + name=name, + role="limb_segment", + kind="ellipsoid", + center=[cx, cy, cz], + rx_m=rx, + ry_m=ry, + rz_m=rz, + placement=placement, + label=name, + ) + ) + # B14 / AI1 P3-5: always include rx= (bridge absence is N/A under joint model) + messages.append( + f"hip_soft_{side}: rx={rx:.4f} ry={ry:.4f} rz={rz:.4f} " + f"outer={outer:.4f} thigh_cap={thigh_cap_outer:.4f} " + f"past_cap={outer - thigh_cap_outer:.4f}" + ) + + def _append_elbow_softs( parts: list[RecipePart], report: ProportionReport, @@ -3296,6 +3347,13 @@ def build_blockout_recipe( # 0046 B9: thigh adduction after limbs+knee+calf exist; before glute outer + join_ready _apply_thigh_adduction(parts, template_applied, messages) + # 0069 B8: anisotropic hip soft at joint (post-adduction; uses final thigh p0 + mid_r) + _append_all_hip_softs( + parts, + height_m=resolved.height_m, + messages=messages, + ) + # 0052: glute seat ry floor + rear +Y (before 0036 outer so rx stays outer-correct) _apply_glute_seat_mass(parts, report, resolved, messages) @@ -5173,6 +5231,12 @@ def run_blockout_emit_setup( "GLUTE_SEAT_Y_FLOOR_M", "GLUTE_SEAT_Z_DROP_FRAC_H", "GLUTE_TOP_OVER_PELVIS_ALLOW_M", + "HIP_SOFT_CENTER", + "HIP_SOFT_RX_SCALE", + "HIP_SOFT_RY_FRAC_RX", + "HIP_SOFT_RZ_FRAC_RX", + "HIP_SOFT_Y_REAR_FRAC_RX", + "HIP_SOFT_Z_DROP_FRAC_H", "JSON_BASENAME", "KNEE_SOFT_FRAC", "KNEE_SOFT_MIN_FRAC_H", @@ -5216,6 +5280,7 @@ def run_blockout_emit_setup( "BlockoutRecipePackage", "RecipeMetrics", "RecipePart", + "_append_all_hip_softs", "_append_elbow_softs", "_apply_glute_seat_mass", "_apply_join_ready_overlaps", diff --git a/src/meshops/proportion/connection_metrics.py b/src/meshops/proportion/connection_metrics.py index 84697c4..953a51a 100644 --- a/src/meshops/proportion/connection_metrics.py +++ b/src/meshops/proportion/connection_metrics.py @@ -226,6 +226,7 @@ def _hip_pair( if child is None: # Proximal thigh capsule fallback (0045 B11/P3-2: exclude dist_soft decoys; # 0046 B8: also exclude prox_soft hip beads; + # 0069 B10: exclude hip_soft trochanter mass; # 0070 B10: exclude thigh_taper dist segment — never wins outer/hip_pair). for p in parts: if ( @@ -233,6 +234,7 @@ def _hip_pair( and f"thigh_{side}" in p.name and "dist_soft" not in p.name and "prox_soft" not in p.name + and "hip_soft" not in p.name and "thigh_taper" not in p.name ): child = p diff --git a/src/meshops/proportion/constraints.py b/src/meshops/proportion/constraints.py index b45bc3e..fc23f48 100644 --- a/src/meshops/proportion/constraints.py +++ b/src/meshops/proportion/constraints.py @@ -279,12 +279,15 @@ def classify_part_name(name: str) -> tuple[ConstraintRole, Side]: # 0045 limb visual mass softs: before generic thigh/upper_arm/forearm (B6). # dist_soft must not label as arm ConstraintRole; knee_soft explicit pin. # 0046 B7: prox_soft before generic thigh (C_no_dup safe). + # 0069 B9: hip_soft → unknown (defensive; free-set skip); keep legacy prox_soft. # 0070 B5: thigh_taper (dist shaft seg) → unknown before generic thigh (C_no_dup). # 0062 B8: arm_taper + elbow_soft → unknown before generic upper_arm/forearm. if "knee_soft" in lower: return "unknown", side if "dist_soft" in lower: return "unknown", side + if "hip_soft" in lower: + return "unknown", side if "prox_soft" in lower: return "unknown", side if "thigh_taper" in lower: diff --git a/tests/test_proportion_blockout_recipe.py b/tests/test_proportion_blockout_recipe.py index b0f6cfb..1025605 100644 --- a/tests/test_proportion_blockout_recipe.py +++ b/tests/test_proportion_blockout_recipe.py @@ -693,7 +693,7 @@ def _limb_mass_report( def test_recipe__t3_thigh_no_dist_soft() -> None: - """0045 T3/B13 + 0046 + 0070: prox + taper_dist + prox_soft; no dist_soft thigh.""" + """0045 T3/B13 + 0046 + 0070 + 0069: prox + taper_dist + hip_soft; no dist_soft thigh.""" report = _limb_mass_report() pkg = build_blockout_recipe(report, limbs=True) by_name = {p.name: p for p in pkg.parts} @@ -708,9 +708,11 @@ def test_recipe__t3_thigh_no_dist_soft() -> None: assert not any("thigh" in n for n in soft_names) assert "RECIPE_dist_soft_thigh_l" not in by_name assert "RECIPE_dist_soft_thigh_r" not in by_name - # 0046: prox soft present - assert "RECIPE_prox_soft_thigh_l" in by_name - assert "RECIPE_prox_soft_thigh_r" in by_name + # 0069: anisotropic hip soft present; legacy prox_soft gone + assert "RECIPE_hip_soft_l" in by_name + assert "RECIPE_hip_soft_r" in by_name + assert "RECIPE_prox_soft_thigh_l" not in by_name + assert "RECIPE_prox_soft_thigh_r" not in by_name def test_recipe__t4_arm_dist_soft_scale() -> None: @@ -949,45 +951,61 @@ def test_recipe__t2_base_deltoid_scale() -> None: def test_recipe__t4_thigh_prox_soft_emit() -> None: - """0046 T4 + 0070: thigh emits prox + taper_dist + prox_soft; no dist_soft thigh.""" + """0046 T4 + 0070 + 0069: thigh emits prox + taper_dist + hip_soft; no dist_soft thigh.""" + from meshops.proportion.blockout_recipe import HIP_SOFT_Y_REAR_FRAC_RX, HIP_SOFT_Z_DROP_FRAC_H + report = _limb_mass_report() pkg = build_blockout_recipe(report, limbs=True) by_name = {p.name: p for p in pkg.parts} + h = float(report.height_m) if report.height_m is not None else None for side in ("l", "r"): assert f"RECIPE_limb_thigh_{side}" in by_name assert f"RECIPE_thigh_taper_dist_{side}" in by_name - soft = by_name[f"RECIPE_prox_soft_thigh_{side}"] + soft = by_name[f"RECIPE_hip_soft_{side}"] thigh = by_name[f"RECIPE_limb_thigh_{side}"] assert soft.kind == "ellipsoid" assert soft.role == "limb_segment" assert soft.center is not None and thigh.p0 is not None + # Joint-anchor X; Y may have mild rear; Z may drop when H known assert float(soft.center[0]) == pytest.approx(float(thigh.p0[0])) - assert float(soft.center[1]) == pytest.approx(float(thigh.p0[1])) - assert float(soft.center[2]) == pytest.approx(float(thigh.p0[2])) - # soft r > prox shaft r (1.18x mid; prox shaft = 1.00x mid) assert soft.rx_m is not None and thigh.radius_m is not None + expected_cy = float(thigh.p0[1]) + HIP_SOFT_Y_REAR_FRAC_RX * float(soft.rx_m) + assert float(soft.center[1]) == pytest.approx(expected_cy, abs=1e-9) + if h is not None: + expected_cz = float(thigh.p0[2]) - HIP_SOFT_Z_DROP_FRAC_H * h + assert float(soft.center[2]) == pytest.approx(expected_cz, abs=1e-9) + # past-cap: soft rx > prox shaft r (anisotropic, not sphere) assert float(soft.rx_m) > float(thigh.radius_m) - assert any(f"thigh_{side}: prox_soft r=" in m for m in pkg.messages) + assert soft.ry_m is not None and soft.rz_m is not None + assert float(soft.ry_m) < float(soft.rx_m) + assert float(soft.rz_m) < float(soft.rx_m) + assert any(f"hip_soft_{side}: rx=" in m for m in pkg.messages) + assert "RECIPE_prox_soft_thigh_l" not in by_name + assert "RECIPE_prox_soft_thigh_r" not in by_name assert "RECIPE_dist_soft_thigh_l" not in by_name assert "RECIPE_dist_soft_thigh_r" not in by_name def test_recipe__t5_thigh_prox_soft_scale() -> None: - """0046 T5 + 0070: prox soft r ~ measured mid_r * THIGH_PROX_SOFT_SCALE (not shaft alone).""" - from meshops.proportion.blockout_recipe import THIGH_PROX_SOFT_SCALE + """0046 T5 + 0070 + 0069: hip_soft rx ~ mid*1.15; ry/rz anisotropic fracs.""" + from meshops.proportion.blockout_recipe import ( + HIP_SOFT_RX_SCALE, + HIP_SOFT_RY_FRAC_RX, + HIP_SOFT_RZ_FRAC_RX, + ) thigh_hw = 0.06 report = _limb_mass_report(thigh_hw=thigh_hw) pkg = build_blockout_recipe(report, limbs=True) by_name = {p.name: p for p in pkg.parts} - expected = max(thigh_hw * THIGH_PROX_SOFT_SCALE, 1e-4) + expected_rx = max(thigh_hw * HIP_SOFT_RX_SCALE, 1e-4) for side in ("l", "r"): - soft = by_name[f"RECIPE_prox_soft_thigh_{side}"] + soft = by_name[f"RECIPE_hip_soft_{side}"] prox = by_name[f"RECIPE_limb_thigh_{side}"] - assert soft.rx_m == pytest.approx(expected, abs=1e-9) - assert soft.ry_m == pytest.approx(expected, abs=1e-9) - assert soft.rz_m == pytest.approx(expected, abs=1e-9) - # Prox shaft scale 1.0 → prox r == mid; soft still vs measured mid + assert soft.rx_m == pytest.approx(expected_rx, abs=1e-9) + assert soft.ry_m == pytest.approx(expected_rx * HIP_SOFT_RY_FRAC_RX, abs=1e-9) + assert soft.rz_m == pytest.approx(expected_rx * HIP_SOFT_RZ_FRAC_RX, abs=1e-9) + # Prox shaft scale 1.0 → prox r == mid; soft rx vs measured mid * 1.15 assert float(prox.radius_m) == pytest.approx(thigh_hw, abs=1e-9) # type: ignore[arg-type] @@ -1130,7 +1148,8 @@ def test_recipe__t8_0045_fences_after_adduction() -> None: cyl = by[f"RECIPE_calf_cyl_{side}"] assert cyl.radius_m == pytest.approx(0.05 * CALF_BELLY_SCALE, abs=1e-9) assert f"RECIPE_knee_soft_{side}" in by - assert f"RECIPE_prox_soft_thigh_{side}" in by + assert f"RECIPE_hip_soft_{side}" in by + assert f"RECIPE_prox_soft_thigh_{side}" not in by assert f"RECIPE_dist_soft_thigh_{side}" not in by assert f"RECIPE_thigh_taper_dist_{side}" in by # Knee cluster still attached to chain end after adduction diff --git a/tests/test_proportion_constraints.py b/tests/test_proportion_constraints.py index b3bad5f..26d360f 100644 --- a/tests/test_proportion_constraints.py +++ b/tests/test_proportion_constraints.py @@ -191,6 +191,12 @@ def test_classify__t0_prox_soft_unknown() -> None: assert classify_part_name("RECIPE_prox_soft_thigh_r") == ("unknown", "r") +def test_classify__t0_hip_soft_unknown() -> None: + """0069 B9: RECIPE_hip_soft_* → unknown (not thigh).""" + assert classify_part_name("RECIPE_hip_soft_l") == ("unknown", "l") + assert classify_part_name("RECIPE_hip_soft_r") == ("unknown", "r") + + def test_classify__t0_thigh_taper_unknown() -> None: """0070 B5: RECIPE_thigh_taper_dist_* → unknown before generic thigh match.""" assert classify_part_name("RECIPE_thigh_taper_dist_l") == ("unknown", "l") @@ -1763,7 +1769,7 @@ def test_hip_pair__t11_excludes_dist_soft_decoy() -> None: def test_hip_pair__t11_excludes_prox_soft_decoy() -> None: - """0046 T11/B8: _hip_pair proximal fallback skips prox_soft decoy.""" + """0046 T11/B8 + 0069 B10: _hip_pair proximal fallback skips prox_soft + hip_soft decoys.""" from meshops.proportion.connection_metrics import _hip_pair thigh = _part( @@ -1785,6 +1791,14 @@ def test_hip_pair__t11_excludes_prox_soft_decoy() -> None: ry_m=0.07, rz_m=0.07, ) + hip_soft_decoy = _part( + "RECIPE_hip_soft_l", + kind="ellipsoid", + center=[0.12, 0.0, 0.95], + rx_m=0.07, + ry_m=0.06, + rz_m=0.05, + ) pelvis = _part( "RECIPE_pelvis_oval", role="pelvis", @@ -1794,7 +1808,7 @@ def test_hip_pair__t11_excludes_prox_soft_decoy() -> None: ry_m=0.08, rz_m=0.06, ) - parts = [decoy, thigh, pelvis] + parts = [decoy, hip_soft_decoy, thigh, pelvis] by_name = {p.name: p for p in parts} pair = _hip_pair(parts, by_name, "l") assert pair is not None @@ -1802,6 +1816,7 @@ def test_hip_pair__t11_excludes_prox_soft_decoy() -> None: assert child.name == "RECIPE_custom_thigh_l" assert parent.name == "RECIPE_pelvis_oval" assert "prox_soft" not in child.name + assert "hip_soft" not in child.name def test_hip_pair__t11_excludes_thigh_taper_decoy() -> None: diff --git a/tests/test_proportion_hip_soft_cluster.py b/tests/test_proportion_hip_soft_cluster.py new file mode 100644 index 0000000..c8a2297 --- /dev/null +++ b/tests/test_proportion_hip_soft_cluster.py @@ -0,0 +1,502 @@ +"""Track 0069 - Hip soft cluster simplify: anisotropic trochanter at joint (T0-T15).""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from meshops.proportion.blockout_recipe import ( + GLUTE_SEAT_BEYOND_REF_Y, + GLUTE_SEAT_Z_DROP_FRAC_H, + HIP_SOFT_RX_SCALE, + HIP_SOFT_RY_FRAC_RX, + HIP_SOFT_RZ_FRAC_RX, + HIP_SOFT_Y_REAR_FRAC_RX, + HIP_SOFT_Z_DROP_FRAC_H, + THIGH_DIST_SHAFT_SCALE, + THIGH_PROX_SHAFT_SCALE, + THIGH_PROX_SOFT_SCALE, + THIGH_SPLIT_T, + BlockoutRecipePackage, + build_blockout_recipe, +) +from meshops.proportion.connection_metrics import _hip_pair +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 | None = 1.72, + thigh_hw: float = 0.0613, + calf_hw: float = 0.05, + arm_hw: float = 0.04, + hip_x: float = 0.2224, +) -> ProportionReport: + """Synthetic full-limb report; product-like mid thigh / hip joint spacing.""" + hx = abs(hip_x) + lms = { + "crotch": _lm("crotch", x_m=0.0, y_m=0.0, z_m=0.90), + "hip_l": _lm("hip_l", x_m=-hx, y_m=0.0, z_m=0.95), + "hip_r": _lm("hip_r", x_m=hx, 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), + "knee_l": _lm("knee_l", x_m=-hx, y_m=0.04, z_m=0.50), + "knee_r": _lm("knee_r", x_m=hx, 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), + } + 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), + ] + 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, +) -> 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-T15 +# --------------------------------------------------------------------------- + + +def test_t0_classifier_hip_soft_unknown() -> None: + """T0: classifier RECIPE_hip_soft_{l,r} → (unknown, side).""" + assert classify_part_name("RECIPE_hip_soft_l") == ("unknown", "l") + assert classify_part_name("RECIPE_hip_soft_r") == ("unknown", "r") + + +def test_t0b_legacy_prox_soft_unknown() -> None: + """T0b: legacy RECIPE_prox_soft_thigh_* still unknown.""" + assert classify_part_name("RECIPE_prox_soft_thigh_l") == ("unknown", "l") + assert classify_part_name("RECIPE_prox_soft_thigh_r") == ("unknown", "r") + + +def test_t1_hip_soft_present_no_prox_soft() -> None: + """T1: product-like limbs → hip_soft present; no prox_soft_thigh.""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True) + by_name = {p.name: p for p in pkg.parts} + assert "RECIPE_hip_soft_l" in by_name + assert "RECIPE_hip_soft_r" in by_name + assert "RECIPE_prox_soft_thigh_l" not in by_name + assert "RECIPE_prox_soft_thigh_r" not in by_name + soft_names = [p.name for p in pkg.parts if "prox_soft" in p.name.lower()] + assert soft_names == [] + + +def test_t2_anisotropic_axes() -> None: + """T2: ry < rx and rz < rx (de-sphere).""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True) + by_name = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + soft = by_name[f"RECIPE_hip_soft_{side}"] + assert soft.rx_m is not None and soft.ry_m is not None and soft.rz_m is not None + assert float(soft.ry_m) < float(soft.rx_m) + assert float(soft.rz_m) < float(soft.rx_m) + assert float(soft.ry_m) == pytest.approx(float(soft.rx_m) * HIP_SOFT_RY_FRAC_RX, abs=1e-9) + assert float(soft.rz_m) == pytest.approx(float(soft.rx_m) * HIP_SOFT_RZ_FRAC_RX, abs=1e-9) + + +def test_t3_past_cap_visibility() -> None: + """T3: |soft outer| > |thigh p0.x| + thigh.r - 1e-4 (AI1 P2-1).""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True) + by_name = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + soft = by_name[f"RECIPE_hip_soft_{side}"] + thigh = by_name[f"RECIPE_limb_thigh_{side}"] + assert soft.center is not None and soft.rx_m is not None + assert thigh.p0 is not None and thigh.radius_m is not None + soft_outer = abs(float(soft.center[0])) + float(soft.rx_m) + thigh_cap = abs(float(thigh.p0[0])) + float(thigh.radius_m) + assert soft_outer > thigh_cap - 1e-4 + + +def test_t4_joint_anchor_center() -> None: + """T4: center X == thigh.p0[0]; center Z ≤ hip_z when H known.""" + report = _limb_mass_report(height_m=1.72) + pkg = build_blockout_recipe(report, limbs=True) + by_name = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + soft = by_name[f"RECIPE_hip_soft_{side}"] + thigh = by_name[f"RECIPE_limb_thigh_{side}"] + assert soft.center is not None and thigh.p0 is not None + assert float(soft.center[0]) == pytest.approx(float(thigh.p0[0]), abs=1e-9) + assert float(soft.center[2]) <= float(thigh.p0[2]) + 1e-9 + + +def test_t5_z_drop_and_h_missing() -> None: + """T5: Z drop never raises; H-missing still emits.""" + h = 1.72 + report = _limb_mass_report(height_m=h) + pkg = build_blockout_recipe(report, limbs=True) + by_name = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + soft = by_name[f"RECIPE_hip_soft_{side}"] + thigh = by_name[f"RECIPE_limb_thigh_{side}"] + assert soft.center is not None and thigh.p0 is not None + expected_z = float(thigh.p0[2]) - HIP_SOFT_Z_DROP_FRAC_H * h + assert float(soft.center[2]) == pytest.approx(expected_z, abs=1e-9) + assert float(soft.center[2]) < float(thigh.p0[2]) + + report_no_h = _limb_mass_report(height_m=None) + pkg_no_h = build_blockout_recipe(report_no_h, limbs=True) + by_no = {p.name: p for p in pkg_no_h.parts} + assert "RECIPE_hip_soft_l" in by_no + assert "RECIPE_hip_soft_r" in by_no + for side in ("l", "r"): + soft = by_no[f"RECIPE_hip_soft_{side}"] + thigh = by_no[f"RECIPE_limb_thigh_{side}"] + assert soft.center is not None and thigh.p0 is not None + # No H → no Z drop; center Z == hip joint Z + assert float(soft.center[2]) == pytest.approx(float(thigh.p0[2]), abs=1e-9) + + +def test_t6_dual_sides() -> None: + """T6: dual sides L/R; names side-tagged.""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True) + softs = [p for p in pkg.parts if p.name.startswith("RECIPE_hip_soft_")] + assert len(softs) == 2 + names = {p.name for p in softs} + assert names == {"RECIPE_hip_soft_l", "RECIPE_hip_soft_r"} + by = {p.name: p for p in softs} + assert by["RECIPE_hip_soft_l"].center is not None + assert by["RECIPE_hip_soft_r"].center is not None + assert float(by["RECIPE_hip_soft_l"].center[0]) < 0 + assert float(by["RECIPE_hip_soft_r"].center[0]) > 0 + + +def test_t7_hip_pair_excludes_hip_soft() -> None: + """T7: _hip_pair excludes hip_soft decoy.""" + thigh = _part( + "RECIPE_custom_thigh_l", + kind="capsule", + radius_m=0.06, + p0=[0.12, 0.0, 0.95], + p1=[0.12, 0.0, 0.50], + ) + decoy = _part( + "RECIPE_hip_soft_l", + kind="ellipsoid", + center=[0.12, 0.0, 0.95], + rx_m=0.07, + ry_m=0.06, + rz_m=0.05, + ) + pelvis = _part( + "RECIPE_pelvis_oval", + role="pelvis", + kind="ellipsoid", + center=[0.0, 0.0, 0.90], + rx_m=0.12, + ry_m=0.08, + rz_m=0.06, + ) + parts = [decoy, thigh, pelvis] + by_name = {p.name: p for p in parts} + pair = _hip_pair(parts, by_name, "l") + assert pair is not None + child, parent = pair + assert child.name == "RECIPE_custom_thigh_l" + assert parent.name == "RECIPE_pelvis_oval" + assert "hip_soft" not in child.name + + +def test_t8_no_dup_with_hip_soft() -> None: + """T8: C_no_dup_limb green with hip_soft + taper_dist + limb_thigh.""" + parts = [ + _part( + "RECIPE_limb_thigh_l", + radius_m=0.0613, + p0=[0.12, 0.0, 0.95], + p1=[0.12, 0.0, 0.725], + ), + _part( + "RECIPE_thigh_taper_dist_l", + radius_m=0.0490, + p0=[0.12, 0.0, 0.725], + p1=[0.12, 0.0, 0.50], + ), + _part( + "RECIPE_hip_soft_l", + kind="ellipsoid", + center=[0.12, 0.0, 0.95], + rx_m=0.0705, + ry_m=0.0620, + rz_m=0.0493, + ), + _part( + "RECIPE_knee_soft_l", + kind="ellipsoid", + center=[0.12, 0.0, 0.50], + rx_m=0.0337, + ry_m=0.0337, + rz_m=0.0337, + ), + _part( + "RECIPE_calf_a_l", + kind="ellipsoid", + center=[0.12, 0.0, 0.50], + rx_m=0.0385, + ry_m=0.0385, + rz_m=0.0385, + ), + _part( + "RECIPE_calf_cyl_l", + radius_m=0.0473, + p0=[0.12, 0.0, 0.50], + p1=[0.12, 0.04, 0.12], + ), + _part( + "RECIPE_calf_b_l", + kind="ellipsoid", + center=[0.12, 0.04, 0.12], + rx_m=0.0315, + ry_m=0.0315, + rz_m=0.0315, + ), + ] + 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_t9_thigh_outer_green() -> None: + """T9: C_thigh_outer green with hip_soft present (bridge/thigh SoT; soft unknown).""" + from meshops.proportion.constraints import OUTER_X_TOL_M + + # Pre-aligned bridge outer ≈ thigh chain mid outer (same spirit as 0070 T9). + hip = [-0.12, 0.0, 0.95] + knee = [-0.12, 0.0, 0.50] + mid = [0.5 * (hip[0] + knee[0]), 0.5 * (hip[1] + knee[1]), 0.5 * (hip[2] + knee[2])] + r = 0.06 + chain_mid_x = mid[0] + chain_outer = chain_mid_x - r # left outer + hip_half = 0.03 + hip_cx = chain_outer + hip_half + parts = [ + _part( + "RECIPE_hip_bridge_l", + role="hip_bridge", + kind="ellipsoid", + center=[hip_cx, 0.03, 0.95], + rx_m=hip_half, + ry_m=0.03, + rz_m=0.03, + ), + _part( + "RECIPE_limb_thigh_l", + radius_m=r, + p0=list(hip), + p1=list(mid), + ), + _part( + "RECIPE_thigh_taper_dist_l", + radius_m=r * 0.8, + p0=list(mid), + p1=list(knee), + ), + _part( + "RECIPE_hip_soft_l", + kind="ellipsoid", + center=list(hip), + rx_m=r * HIP_SOFT_RX_SCALE, + ry_m=r * HIP_SOFT_RX_SCALE * HIP_SOFT_RY_FRAC_RX, + rz_m=r * HIP_SOFT_RX_SCALE * HIP_SOFT_RZ_FRAC_RX, + ), + ] + pkg = BlockoutRecipePackage(parts=parts, counts={"parts": len(parts)}) + result = validate_constraints(pkg) + by_id = {r.id: r for r in result.rules} + assert "C_thigh_outer" in by_id + assert by_id["C_thigh_outer"].status == "pass", by_id["C_thigh_outer"].message + assert by_id["C_thigh_outer"].metrics is not None + assert float(by_id["C_thigh_outer"].metrics["delta_l"]) <= OUTER_X_TOL_M + 1e-9 + # hip_soft does not classify as thigh / break free-set + assert classify_part_name("RECIPE_hip_soft_l") == ("unknown", "l") + + +def test_t10_glute_outer_align_0036() -> None: + """T10: 0036 glute outer still aligns to hip_bridge.""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True, glute="two_spheres") + assert any("outer X aligned to hip_bridge" in m for m in pkg.messages) + result = validate_constraints(pkg, report=report) + by_id = {r.id: r for r in result.rules} + assert by_id["C_glute_outer"].status == "pass", by_id["C_glute_outer"].message + + +def test_t11_message_rx_and_past_cap() -> None: + """T11: message contains hip_soft_{side}: rx= and past_cap=.""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True) + for side in ("l", "r"): + msgs = [m for m in pkg.messages if m.startswith(f"hip_soft_{side}:")] + assert msgs, f"missing hip_soft_{side} message" + m = msgs[0] + assert f"hip_soft_{side}: rx=" in m + assert "past_cap=" in m + assert "outer=" in m + assert "thigh_cap=" in m + + +def test_t12_rx_ge_mid_r() -> None: + """T12: rx ≥ mid_r (scale ≥1.0); soft not buried.""" + mid_r = 0.0613 + report = _limb_mass_report(thigh_hw=mid_r) + pkg = build_blockout_recipe(report, limbs=True) + by_name = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + soft = by_name[f"RECIPE_hip_soft_{side}"] + thigh = by_name[f"RECIPE_limb_thigh_{side}"] + assert soft.rx_m is not None and thigh.radius_m is not None + assert float(soft.rx_m) >= float(thigh.radius_m) - 1e-9 + assert float(soft.rx_m) == pytest.approx(mid_r * HIP_SOFT_RX_SCALE, abs=1e-5) + + +def test_t13_0070_taper_fences() -> None: + """T13: 0070 fences — taper_dist + shaft scales unchanged.""" + mid_r = 0.0613 + report = _limb_mass_report(thigh_hw=mid_r) + pkg = build_blockout_recipe(report, limbs=True) + by_name = {p.name: p for p in pkg.parts} + assert THIGH_PROX_SHAFT_SCALE == 1.0 + assert THIGH_DIST_SHAFT_SCALE == 0.8 + assert THIGH_SPLIT_T == 0.5 + for side in ("l", "r"): + prox = by_name[f"RECIPE_limb_thigh_{side}"] + dist = by_name[f"RECIPE_thigh_taper_dist_{side}"] + assert float(prox.radius_m) == pytest.approx( # type: ignore[arg-type] + mid_r * THIGH_PROX_SHAFT_SCALE, abs=1e-9 + ) + assert float(dist.radius_m) == pytest.approx( # type: ignore[arg-type] + mid_r * THIGH_DIST_SHAFT_SCALE, abs=1e-9 + ) + + +def test_t14_0068_seat_freezes() -> None: + """T14: 0068 seat freezes untouched (import smoke + glute path still runs).""" + assert GLUTE_SEAT_Z_DROP_FRAC_H == 0.035 + assert GLUTE_SEAT_BEYOND_REF_Y == 0.035 + # Fence: legacy prox soft const still importable (not used for product emit) + assert THIGH_PROX_SOFT_SCALE == 1.18 + assert HIP_SOFT_RX_SCALE == 1.15 + assert HIP_SOFT_Y_REAR_FRAC_RX == 0.12 + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True, glute="two_spheres") + glutes = [p for p in pkg.parts if p.role == "glute_soft"] + assert len(glutes) >= 1 + assert any("glute" in m.lower() or "seat" in m.lower() for m in pkg.messages) or glutes + + +def test_t15_no_bridge_outer_clamp_bury() -> None: + """T15: soft outer may exceed bridge outer (honest; not a fail).""" + report = _limb_mass_report() + pkg = build_blockout_recipe(report, limbs=True) + by_name = {p.name: p for p in pkg.parts} + for side in ("l", "r"): + soft = by_name[f"RECIPE_hip_soft_{side}"] + bridge = by_name.get(f"RECIPE_hip_bridge_{side}") + assert soft.center is not None and soft.rx_m is not None + soft_outer = abs(float(soft.center[0])) + float(soft.rx_m) + if bridge is not None and bridge.p0 is not None and bridge.radius_m is not None: + # bridge outer ≈ max |p0.x|,|p1.x| + r (cylinder along X typically) + xs = [float(bridge.p0[0])] + if bridge.p1 is not None: + xs.append(float(bridge.p1[0])) + bridge_outer = max(abs(x) for x in xs) + float(bridge.radius_m) + # Joint model: soft may (and typically does) exceed bridge — not a fail + assert soft_outer > bridge_outer - 1e-3 diff --git a/tests/test_proportion_thigh_shaft_taper.py b/tests/test_proportion_thigh_shaft_taper.py index 0e99fc1..c1b3dca 100644 --- a/tests/test_proportion_thigh_shaft_taper.py +++ b/tests/test_proportion_thigh_shaft_taper.py @@ -8,6 +8,7 @@ import pytest from meshops.proportion.blockout_recipe import ( + HIP_SOFT_RX_SCALE, THIGH_ADDUCTION_MAX_MEDIAL_M, THIGH_DIST_SHAFT_SCALE, THIGH_PROX_SHAFT_SCALE, @@ -237,7 +238,9 @@ def test_t4_no_dist_soft_thigh() -> None: by_name = {p.name: p for p in pkg.parts} assert "RECIPE_dist_soft_thigh_l" not in by_name assert "RECIPE_dist_soft_thigh_r" not in by_name - assert "RECIPE_prox_soft_thigh_l" in by_name + # 0069: product path emits hip_soft (not prox_soft) + assert "RECIPE_hip_soft_l" in by_name + assert "RECIPE_prox_soft_thigh_l" not in by_name def test_t5_no_dup_with_both_segments() -> None: @@ -593,9 +596,9 @@ def test_t10_product_width_mid() -> None: assert float(dist.radius_m) == pytest.approx(0.04904, abs=1e-5) # type: ignore[arg-type] prox = by_name["RECIPE_limb_thigh_l"] assert float(prox.radius_m) == pytest.approx(0.0613, abs=1e-5) # type: ignore[arg-type] - soft = by_name["RECIPE_prox_soft_thigh_l"] + soft = by_name["RECIPE_hip_soft_l"] assert float(soft.rx_m) == pytest.approx( # type: ignore[arg-type] - mid_r * THIGH_PROX_SOFT_SCALE, abs=1e-5 + mid_r * HIP_SOFT_RX_SCALE, abs=1e-5 )