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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 83 additions & 18 deletions src/meshops/proportion/blockout_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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"):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions src/meshops/proportion/connection_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,13 +226,15 @@ 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 (
p.role == "limb_segment"
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
Expand Down
3 changes: 3 additions & 0 deletions src/meshops/proportion/constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
57 changes: 38 additions & 19 deletions tests/test_proportion_blockout_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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:
Expand Down Expand Up @@ -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]


Expand Down Expand Up @@ -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
Expand Down
19 changes: 17 additions & 2 deletions tests/test_proportion_constraints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand All @@ -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",
Expand All @@ -1794,14 +1808,15 @@ 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
child, parent = pair
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:
Expand Down
Loading