From 4d98ef1480e2f61a2de56f1a38c8fd7d117b7597 Mon Sep 17 00:00:00 2001 From: Ryan Date: Sun, 23 Aug 2026 13:26:12 -0400 Subject: [PATCH] feat(0126): hip/glute landmark capture, compare, and placement consume --- src/meshops/cli.py | 70 +- src/meshops/mcp/server.py | 23 + src/meshops/mcp/tools.py | 27 + src/meshops/proportion/assist.py | 38 + src/meshops/proportion/blockout_recipe.py | 37 +- src/meshops/proportion/fuse.py | 14 +- src/meshops/proportion/hip_glute_compare.py | 673 ++++++++++++++++++ src/meshops/proportion/honesty.py | 1 + src/meshops/proportion/template.py | 19 + tests/test_breast_costume_docs.py | 2 +- tests/test_fist_gesture_docs.py | 2 +- tests/test_foot_stack_docs.py | 2 +- tests/test_hip_soft_docs.py | 7 +- tests/test_limb_soft_scale_docs.py | 2 +- tests/test_mcp_server.py | 15 +- tests/test_michelin_cap_docs.py | 2 +- tests/test_one_character_loomis_docs.py | 2 +- tests/test_proportion_anatomy_profile.py | 4 +- tests/test_proportion_arm_elbow_hang.py | 2 +- tests/test_proportion_breast_chest_contact.py | 2 +- tests/test_proportion_breast_hang.py | 2 +- ...t_proportion_breast_hang_after_shoulder.py | 2 +- tests/test_proportion_breast_lower_pole.py | 2 +- tests/test_proportion_calf_shaft_form.py | 2 +- tests/test_proportion_calf_split_resync.py | 2 +- ...t_proportion_deltoid_anti_michelin_plus.py | 2 +- tests/test_proportion_deltoid_michelin_cap.py | 2 +- tests/test_proportion_extremity_recipe.py | 6 +- tests/test_proportion_face_compare.py | 2 +- ...st_proportion_face_orbital_lip_defaults.py | 2 +- tests/test_proportion_face_recipe.py | 4 +- tests/test_proportion_face_soft_consume.py | 2 +- tests/test_proportion_foot_scale_plus.py | 2 +- ...est_proportion_foot_sphere_stack_polish.py | 2 +- tests/test_proportion_foot_stack_hierarchy.py | 2 +- tests/test_proportion_generic_michelin_cap.py | 2 +- tests/test_proportion_hand_digit_curl.py | 2 +- tests/test_proportion_hand_digit_taper.py | 2 +- tests/test_proportion_head_face_hierarchy.py | 2 +- .../test_proportion_heel_ankle_hw_fixture.py | 2 +- tests/test_proportion_hip_glute_compare.py | 640 +++++++++++++++++ tests/test_proportion_hip_glute_landmarks.py | 206 ++++++ .../test_proportion_hip_glute_soft_consume.py | 197 +++++ tests/test_proportion_hip_hierarchy.py | 2 +- ...test_proportion_hip_soft_hierarchy_plus.py | 2 +- tests/test_proportion_knee_bead_soften.py | 2 +- tests/test_proportion_limb_shaft_form_plus.py | 2 +- ...est_proportion_mid_back_waist_integrate.py | 2 +- tests/test_proportion_neck_nape_setback.py | 2 +- tests/test_proportion_setup_launch.py | 6 +- tests/test_proportion_skeleton.py | 2 +- .../test_proportion_skeleton_hang_message.py | 2 +- ...test_proportion_thigh_distal_taper_plus.py | 2 +- tests/test_proportion_torso_anti_tire_plus.py | 2 +- tests/test_proportion_torso_compare.py | 2 +- tests/test_proportion_torso_continuous.py | 2 +- tests/test_proportion_torso_soft_consume.py | 2 +- tests/test_proportion_torso_thoracic_front.py | 2 +- ...t_proportion_validate_template_optional.py | 2 +- tests/test_remake_policy_docs.py | 2 +- tests/test_sculpt_handoff_docs.py | 2 +- tests/test_viewport_soft_hide_docs.py | 2 +- 62 files changed, 2006 insertions(+), 69 deletions(-) create mode 100644 src/meshops/proportion/hip_glute_compare.py create mode 100644 tests/test_proportion_hip_glute_compare.py create mode 100644 tests/test_proportion_hip_glute_landmarks.py create mode 100644 tests/test_proportion_hip_glute_soft_consume.py diff --git a/src/meshops/cli.py b/src/meshops/cli.py index 2e4b27d..3c5fd6b 100644 --- a/src/meshops/cli.py +++ b/src/meshops/cli.py @@ -120,7 +120,9 @@ "blockout-face-compare photo vs RECIPE vs optional scene " "(0124; FACE_COMPARE_HONESTY — N6); " "blockout-torso-compare photo vs RECIPE vs optional scene " - "(0125; TORSO_COMPARE_HONESTY — N6). " + "(0125; TORSO_COMPARE_HONESTY — N6); " + "blockout-hip-glute-compare photo vs RECIPE vs optional scene " + "(0126; HIP_GLUTE_COMPARE_HONESTY — N6). " "Optional: meshops[proportion] (Pillow)." ), add_completion=False, @@ -3418,5 +3420,71 @@ def proportion_blockout_torso_compare_cmd( raise typer.Exit(0) +@proportion_app.command("blockout-hip-glute-compare") +def proportion_blockout_hip_glute_compare_cmd( + report: Path = typer.Option( + ..., + "--report", + help="Path to proportion_report.json (required)", + ), + recipe: Path = typer.Option( + ..., + "--recipe", + help="Path to blockout_recipe.json (required)", + ), + out: Path = typer.Option( + ..., + "--out", + help="Output directory for hip_glute_compare.json (required)", + ), + scene_dump: Path | None = typer.Option( + None, + "--scene-dump", + help="Optional live Blender/scene dump JSON overlay", + ), + force: bool = typer.Option( + False, + "--force", + help="Overwrite existing hip_glute_compare.json", + ), + json_out: bool = typer.Option(False, "--json", help="Emit machine result JSON"), +) -> None: + """Compare Package A hip/glute landmarks vs RECIPE vs optional live scene. + + Authoring QA only — not mesh or print success (HIP_GLUTE_COMPARE_HONESTY). + """ + from meshops.proportion.errors import ProportionError + from meshops.proportion.hip_glute_compare import run_blockout_hip_glute_compare + from meshops.proportion.honesty import HIP_GLUTE_COMPARE_HONESTY + + try: + payload = run_blockout_hip_glute_compare( + report, + recipe, + out, + scene_dump=scene_dump, + force=force, + ) + except ProportionError as exc: + _emit_error(exc, json_mode=json_out, code=1) + except Exception as exc: + _emit_error(exc, json_mode=json_out) + + if json_out: + _emit_json(payload) + else: + typer.echo( + f"blockout-hip-glute-compare ok={payload.get('ok')} region={payload.get('region')}" + ) + for msg in payload.get("messages") or []: + typer.echo(f" note: {msg}") + pkg_path = payload.get("package_path") + if pkg_path: + typer.echo(f" {pkg_path}") + typer.echo(f"honesty: {HIP_GLUTE_COMPARE_HONESTY}") + typer.echo("blockout-hip-glute-compare authoring QA only — not mesh or print success") + raise typer.Exit(0) + + if __name__ == "__main__": app() diff --git a/src/meshops/mcp/server.py b/src/meshops/mcp/server.py index 2dd3573..8156d41 100644 --- a/src/meshops/mcp/server.py +++ b/src/meshops/mcp/server.py @@ -73,6 +73,7 @@ "mesh_proportion_blockout_feedback", "mesh_proportion_blockout_face_compare", "mesh_proportion_blockout_torso_compare", + "mesh_proportion_blockout_hip_glute_compare", } ) @@ -998,4 +999,26 @@ def mesh_proportion_blockout_torso_compare( force=force, ) + @mcp.tool() + def mesh_proportion_blockout_hip_glute_compare( + report: str, + recipe: str, + out: str, + scene_dump: str | None = None, + force: bool = False, + ) -> dict[str, Any]: + """Compare Package A hip/glute landmarks vs RECIPE vs optional live scene dump. + + Authoring QA only — proportion_hip_glute_compare_not_mesh_or_print_success. + Not mesh or print success. Raises ProportionError on hard failures. + """ + return T.mesh_proportion_blockout_hip_glute_compare( + wr, + report=report, + recipe=recipe, + out=out, + scene_dump=scene_dump, + force=force, + ) + return mcp diff --git a/src/meshops/mcp/tools.py b/src/meshops/mcp/tools.py index 398f8ba..71b7506 100644 --- a/src/meshops/mcp/tools.py +++ b/src/meshops/mcp/tools.py @@ -1403,3 +1403,30 @@ def mesh_proportion_blockout_torso_compare( scene_dump=_resolve_tool_path(scene_dump, work_root) if scene_dump else None, force=force, ) + + +def mesh_proportion_blockout_hip_glute_compare( + work_root: Path, + *, + report: str, + recipe: str, + out: str, + scene_dump: str | None = None, + force: bool = False, +) -> dict[str, Any]: + """Photo vs RECIPE vs optional scene. Authoring only — HIP_GLUTE_COMPARE_HONESTY.""" + from meshops.proportion.hip_glute_compare import run_blockout_hip_glute_compare + + ends_sep = out.endswith(("/", "\\")) + out_base = out.rstrip("/\\") if ends_sep else out + out_resolved = _resolve_tool_path(out_base, work_root) + out_arg: str | Path = ( + str(out_resolved) + ("\\" if ends_sep else "") if ends_sep else out_resolved + ) + return run_blockout_hip_glute_compare( + _resolve_tool_path(report, work_root), + _resolve_tool_path(recipe, work_root), + out_arg, + scene_dump=_resolve_tool_path(scene_dump, work_root) if scene_dump else None, + force=force, + ) diff --git a/src/meshops/proportion/assist.py b/src/meshops/proportion/assist.py index 5df5c44..6fadce9 100644 --- a/src/meshops/proportion/assist.py +++ b/src/meshops/proportion/assist.py @@ -127,6 +127,20 @@ "scap_medial_r", "mid_back_l", "mid_back_r", + # 0126 hip/glute/groin form-read (not Pose 23-24 / genital geometry) + "asis_l", + "asis_r", + "psis_l", + "psis_r", + "glute_outer_l", + "glute_outer_r", + "glute_bottom_l", + "glute_bottom_r", + "glute_top_seam", + "groin_fold_l", + "groin_fold_r", + "thigh_medial_l", + "thigh_medial_r", } ) @@ -178,6 +192,30 @@ "mid_back_r", ) +# Frozen v1 hip/glute/groin form-read ids (0126). Existing hip/glute DEPTH stay. +HIP_GLUTE_FRONT_LANDMARK_IDS: tuple[str, ...] = ( + "asis_l", + "asis_r", + "groin_fold_l", + "groin_fold_r", + "thigh_medial_l", + "thigh_medial_r", +) +HIP_GLUTE_LEFT_LANDMARK_IDS: tuple[str, ...] = ( + "glute_bottom_l", + "glute_top_seam", + "psis_l", +) +HIP_GLUTE_BACK_LANDMARK_IDS: tuple[str, ...] = ( + "psis_l", + "psis_r", + "glute_outer_l", + "glute_outer_r", + "glute_bottom_l", + "glute_bottom_r", + "glute_top_seam", +) + def _clamp01(v: float) -> float: return max(0.0, min(1.0, float(v))) diff --git a/src/meshops/proportion/blockout_recipe.py b/src/meshops/proportion/blockout_recipe.py index fe87c6c..7b0a0dc 100644 --- a/src/meshops/proportion/blockout_recipe.py +++ b/src/meshops/proportion/blockout_recipe.py @@ -1823,7 +1823,7 @@ def _apply_shoulder_girdle_softs( def _measured_lm_m(report: ProportionReport, lid: str, *, y: bool) -> float | None: - """Finite landmark y_m or z_m, else None (0125 — never invent).""" + """Finite landmark y_m or z_m, else None (0125/0126 — never invent).""" lm = report.landmarks_xyz.get(lid) if lm is None: return None @@ -5953,6 +5953,41 @@ def _apply_glute_seat_mass( p.center = [float(sc[0]), lock_y, lock_z] messages.append(f"glute_seat: dual lock ry={lock_ry:.4f} y={lock_y:.4f} z={lock_z:.4f}") + # 0126: measured Y/Z after dual lock (B33 — dual lock must not clobber; B32 — do not abs). + overlay_idxs = seated_idxs if seated_idxs else idxs + seam_y = _measured_lm_m(report, "glute_top_seam", y=True) + seam_z = _measured_lm_m(report, "glute_top_seam", y=False) + for i in overlay_idxs: + p = parts[i] + if p.center is None or len(p.center) < 3: + continue + name = p.name or "" + if name.endswith("_l"): + side = "l" + elif name.endswith("_r"): + side = "r" + else: + continue + bottom_id = f"glute_bottom_{side}" + my = _measured_lm_m(report, bottom_id, y=True) + mz = _measured_lm_m(report, bottom_id, y=False) + src_y = bottom_id if my is not None else "glute_top_seam" + src_z = bottom_id if mz is not None else "glute_top_seam" + if my is None: + my = seam_y + if mz is None: + mz = seam_z + if my is None and mz is None: + continue + c = list(p.center) + if my is not None: + c[1] = my + messages.append(f"hip_glute: measured glute y={my:.4f} ({src_y})") + if mz is not None: + c[2] = mz + messages.append(f"hip_glute: measured glute z={mz:.4f} ({src_z})") + p.center = c + # 10. composition observability: glute top/bottom vs pelvis mid/top (B12/B15). if any_seated: pelvis_z: float | None = None diff --git a/src/meshops/proportion/fuse.py b/src/meshops/proportion/fuse.py index 0081353..31bf542 100644 --- a/src/meshops/proportion/fuse.py +++ b/src/meshops/proportion/fuse.py @@ -18,6 +18,8 @@ from meshops.proportion.assist import ( FACE_LEFT_LANDMARK_IDS, + HIP_GLUTE_BACK_LANDMARK_IDS, + HIP_GLUTE_LEFT_LANDMARK_IDS, TORSO_BACK_LANDMARK_IDS, TORSO_LEFT_LANDMARK_IDS, ) @@ -204,7 +206,7 @@ def fuse_xyz( xyz.z_m = z * height_m out[lid] = xyz - # 0125: back-view X/Z for torso form-read ids (not DEPTH_PAIRS; Y from left). + # 0125/0126: back-view X/Z for torso + hip/glute form-read ids (not DEPTH_PAIRS; Y from left). back = views.get("back") if back is not None and back.landmarks: back_span = back.figure_span_px or figure_span_from_landmarks(back) @@ -215,7 +217,7 @@ def fuse_xyz( back.figure_span_px = back_span back_mid = _midline_x(back) invert_x = str(back.facing_direction or "camera_back") == "camera_back" - for lid in TORSO_BACK_LANDMARK_IDS: + for lid in (*TORSO_BACK_LANDMARK_IDS, *HIP_GLUTE_BACK_LANDMARK_IDS): src_lm = back.landmarks.get(lid) if src_lm is None: continue @@ -339,8 +341,12 @@ def fuse_xyz( mid.x_m = x_ref * height_m out[mid_id] = mid - # 0124/0125: same-id left overlay for face + torso form-read Y (not DEPTH_PAIRS). - for lid in (*FACE_LEFT_LANDMARK_IDS, *TORSO_LEFT_LANDMARK_IDS): + # 0124/0125/0126: same-id left overlay for face + torso + hip/glute Y. + for lid in ( + *FACE_LEFT_LANDMARK_IDS, + *TORSO_LEFT_LANDMARK_IDS, + *HIP_GLUTE_LEFT_LANDMARK_IDS, + ): src_lm = left.landmarks.get(lid) if src_lm is None: continue diff --git a/src/meshops/proportion/hip_glute_compare.py b/src/meshops/proportion/hip_glute_compare.py new file mode 100644 index 0000000..a60b66a --- /dev/null +++ b/src/meshops/proportion/hip_glute_compare.py @@ -0,0 +1,673 @@ +"""Hip/glute/groin landmark compare: photo vs RECIPE vs optional live scene (track 0126). + +Authoring QA only — HIP_GLUTE_COMPARE_HONESTY. Not mesh or print success. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path +from typing import Any, Final, Literal + +from pydantic import BaseModel, ConfigDict, Field + +from meshops.proportion.analyze import load_report +from meshops.proportion.blockout_recipe import load_blockout_recipe +from meshops.proportion.errors import ProportionError +from meshops.proportion.honesty import HIP_GLUTE_COMPARE_HONESTY +from meshops.proportion.models import LandmarkXYZ, ProportionReport + +HIP_GLUTE_COMPARE_SCHEMA_VERSION: Final[Literal["1.0.0"]] = "1.0.0" +HIP_GLUTE_COMPARE_JSON: Final[str] = "hip_glute_compare.json" +HIP_GLUTE_METRICS_JSON: Final[str] = "hip_glute_metrics.json" +HIP_GLUTE_COMPARE_ROLES: Final[tuple[str, ...]] = ( + "asis_l", + "asis_r", + "psis_l", + "psis_r", + "glute_outer_l", + "glute_outer_r", + "glute_bottom_l", + "glute_bottom_r", + "glute_top_seam", + "groin_fold_l", + "groin_fold_r", + "thigh_medial_l", + "thigh_medial_r", + "crotch_pubic", + "greater_trochanter", + "glute_peak_l", + "glute_peak_r", + "glute_cleft", + "hip_front", + "hip_back", + "glute_front", + "glute_back", +) +SUGGESTED_ACTIONS: Final[frozenset[str]] = frozenset( + {"skip", "hold_priors", "soft_adjust", "session_hip_glute", "remake_0111"} +) +FORM_READ_TOKENS: Final[frozenset[str]] = frozenset( + { + "glute_short_of_hip_soft_outer", + "pelvis_front_behind_glute", + "thigh_gap_wide", + "glute_midline_gap", + "groin_disconnected", + "missing_id", + } +) +GLUTE_SHORT_OF_HIP_SOFT_M: Final[float] = 0.040 +PELVIS_BEHIND_GLUTE_M: Final[float] = 0.010 +THIGH_GAP_WIDE_M: Final[float] = 0.070 +GROIN_DISCONNECT_M: Final[float] = 0.040 + +_ROLE_PART: dict[str, str] = { + "asis_l": "RECIPE_pelvis_oval", + "asis_r": "RECIPE_pelvis_oval", + "psis_l": "RECIPE_pelvis_oval", + "psis_r": "RECIPE_pelvis_oval", + "glute_outer_l": "RECIPE_glute_soft_l", + "glute_outer_r": "RECIPE_glute_soft_r", + "glute_bottom_l": "RECIPE_glute_soft_l", + "glute_bottom_r": "RECIPE_glute_soft_r", + "glute_top_seam": "RECIPE_glute_soft_l", + "groin_fold_l": "RECIPE_pelvis_oval", + "groin_fold_r": "RECIPE_pelvis_oval", + "thigh_medial_l": "RECIPE_limb_thigh_l", + "thigh_medial_r": "RECIPE_limb_thigh_r", + "crotch_pubic": "RECIPE_pelvis_oval", + "greater_trochanter": "RECIPE_hip_soft_l", + "glute_peak_l": "RECIPE_glute_soft_l", + "glute_peak_r": "RECIPE_glute_soft_r", + "glute_cleft": "RECIPE_glute_soft_l", + "hip_front": "RECIPE_torso_oval_hip", + "hip_back": "RECIPE_torso_oval_hip", + "glute_front": "RECIPE_glute_soft_l", + "glute_back": "RECIPE_glute_soft_l", +} + +# B4: soft_adjust only for new-id glute Y/Z consume (bottom/top_seam). +# glute_outer X / HAVE ids / DEPTH_PAIRS stay hold_priors (no 0036/0106/0092/0068 retune). +_SOFT_ADJUST_IDS: Final[frozenset[str]] = frozenset( + { + "glute_bottom_l", + "glute_bottom_r", + "glute_top_seam", + } +) + +_KNOB: dict[str, str] = { + "asis_l": "PELVIS_OVAL_RY_FRAC_HALF_HIP", + "asis_r": "PELVIS_OVAL_RY_FRAC_HALF_HIP", + "psis_l": "PELVIS_OVAL_RY_FRAC_HALF_HIP", + "psis_r": "PELVIS_OVAL_RY_FRAC_HALF_HIP", + "glute_outer_l": "C_glute_outer", + "glute_outer_r": "C_glute_outer", + "glute_bottom_l": "GLUTE_SEAT_Y_FLOOR_M", + "glute_bottom_r": "GLUTE_SEAT_Y_FLOOR_M", + "glute_top_seam": "GLUTE_SEAT_Y_FLOOR_M", + "groin_fold_l": "GLUTE_SEAT_Y_FLOOR_M", + "groin_fold_r": "GLUTE_SEAT_Y_FLOOR_M", + "thigh_medial_l": "THIGH_DIST_SHAFT_SCALE", + "thigh_medial_r": "THIGH_DIST_SHAFT_SCALE", + "crotch_pubic": "PELVIS_OVAL_RY_FRAC_HALF_HIP", + "greater_trochanter": "HIP_SOFT_RY_FRAC_RX", + "glute_peak_l": "GLUTE_SEAT_Y_FLOOR_M", + "glute_peak_r": "GLUTE_SEAT_Y_FLOOR_M", + "glute_cleft": "GLUTE_SEAT_Y_FLOOR_M", + "hip_front": "TORSO_OVAL_RY_HIP_FRAC", + "hip_back": "TORSO_OVAL_RY_HIP_FRAC", + "glute_front": "GLUTE_SEAT_Y_FLOOR_M", + "glute_back": "GLUTE_SEAT_Y_FLOOR_M", +} + +_KNOWN_DUMP_NAMES: Final[frozenset[str]] = frozenset( + { + "RECIPE_torso_oval_hip", + "RECIPE_pelvis_oval", + "RECIPE_glute_soft_l", + "RECIPE_glute_soft_r", + "RECIPE_hip_soft_l", + "RECIPE_hip_soft_r", + "RECIPE_limb_thigh_l", + "RECIPE_limb_thigh_r", + "RECIPE_thigh_taper_dist_l", + "RECIPE_thigh_taper_dist_r", + } +) + +_THIGH_ROLES: Final[frozenset[str]] = frozenset({"thigh_medial_l", "thigh_medial_r"}) + + +def _as_float(value: object) -> float | None: + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + out = float(value) + return out if math.isfinite(out) else None + + +def _as_vec3(value: object) -> list[float] | None: + if not isinstance(value, (list, tuple)) or len(value) < 3: + return None + coords: list[float] = [] + for i in range(3): + item = _as_float(value[i]) + if item is None: + return None + coords.append(item) + return coords + + +def _as_part_dict(part: Any) -> dict[str, Any]: + if isinstance(part, dict): + return part + dump = getattr(part, "model_dump", None) + if callable(dump): + dumped = dump(mode="json") + if isinstance(dumped, dict): + return dumped + return {} + + +def _part_name(part: Any) -> str: + if isinstance(part, dict): + return str(part.get("name") or "") + name = getattr(part, "name", "") + return str(name) + + +def _midpoint(p0: object, p1: object) -> list[float] | None: + a = _as_vec3(p0) + b = _as_vec3(p1) + if a is None or b is None: + return None + return [(a[i] + b[i]) / 2.0 for i in range(3)] + + +def _center_from_part(part: dict[str, Any], *, prefer_p0: bool = False) -> list[float] | None: + if prefer_p0: + p0 = _as_vec3(part.get("p0")) + if p0 is not None: + return p0 + center = _as_vec3(part.get("center")) + if center is not None: + return center + return _midpoint(part.get("p0"), part.get("p1")) + + +def extract_recipe_hip_glute_part(parts: list[Any], role_id: str) -> dict[str, Any] | None: + """Extract one hip/glute role from RECIPE parts (B26 thigh capsule p0).""" + want = _ROLE_PART.get(role_id) + if want is None: + return None + match: dict[str, Any] | None = None + for part in parts: + if _part_name(part) == want: + match = _as_part_dict(part) + break + if match is None: + return None + kind = str(match.get("kind") or "") + prefer_p0 = role_id in _THIGH_ROLES + center = _center_from_part(match, prefer_p0=prefer_p0) + if kind == "capsule" and center is None: + return None + ry = _as_float(match.get("ry_m")) + if role_id in ("hip_front", "glute_front") and center is not None and ry is not None: + center = [center[0], center[1] - ry, center[2]] + if role_id in ("hip_back", "glute_back") and center is not None and ry is not None: + center = [center[0], center[1] + ry, center[2]] + return { + "name": match.get("name"), + "role": match.get("role"), + "kind": kind or None, + "center": center, + "rx_m": match.get("rx_m"), + "ry_m": match.get("ry_m"), + "rz_m": match.get("rz_m"), + "radius_m": match.get("radius_m"), + "p0": match.get("p0"), + "p1": match.get("p1"), + } + + +class HipGluteMetrics(BaseModel): + """Sidecar hip/glute meters (not a ProportionReport field — stay 1.2.0).""" + + model_config = ConfigDict(extra="forbid") + + schema_version: Literal["1.0.0"] = "1.0.0" + honesty: str = HIP_GLUTE_COMPARE_HONESTY + glute_outer_vs_hip_soft_outer_m: float | None = None + glute_rear_past_hip_oval_m: float | None = None + pelvis_front_vs_glute_front_m: float | None = None + glute_seat_y_m: float | None = None + glute_ry_over_rx: float | None = None + hip_soft_ry_over_rx: float | None = None + thigh_gap_m: float | None = None + y_m: dict[str, float | None] = Field(default_factory=dict) + + +class HipGluteCompareCoord(BaseModel): + model_config = ConfigDict(extra="forbid") + + x_m: float | None = None + y_m: float | None = None + z_m: float | None = None + confidence: float | None = None + sources: list[str] = Field(default_factory=list) + + +class HipGluteCompareDelta(BaseModel): + model_config = ConfigDict(extra="forbid") + + x: float | None = None + y: float | None = None + z: float | None = None + + +class HipGluteCompareRole(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + measured: HipGluteCompareCoord | None = None + recipe: dict[str, Any] | None = None + live: dict[str, Any] | None = None + delta_mm: HipGluteCompareDelta | None = None + knob: str | None = None + form_read: list[str] = Field(default_factory=list) + confidence: float = 0.0 + suggested: Literal["skip", "hold_priors", "soft_adjust", "session_hip_glute", "remake_0111"] = ( + "skip" + ) + + +class HipGluteComparePackage(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: Literal["1.0.0"] = HIP_GLUTE_COMPARE_SCHEMA_VERSION + honesty: str = HIP_GLUTE_COMPARE_HONESTY + region: Literal["hip_glute"] = "hip_glute" + ok: bool = False + roles: list[HipGluteCompareRole] = Field(default_factory=list) + messages: list[str] = Field(default_factory=list) + hip_glute_metrics: HipGluteMetrics | None = None + package_path: str | None = None + + +def _recipe_parts(recipe: Any | None) -> list[Any]: + if recipe is None: + return [] + if isinstance(recipe, dict) and isinstance(recipe.get("parts"), list): + return list(recipe["parts"]) + raw_parts = getattr(recipe, "parts", None) + if isinstance(raw_parts, list): + return list(raw_parts) + return [] + + +def _find_part(parts: list[Any], name: str) -> dict[str, Any] | None: + for part in parts: + if _part_name(part) == name: + return _as_part_dict(part) + return None + + +def _surface_y(part: dict[str, Any] | None, *, rear: bool) -> float | None: + if part is None: + return None + center = _as_vec3(part.get("center")) + ry = _as_float(part.get("ry_m")) + if center is None or ry is None: + return None + return center[1] + ry if rear else center[1] - ry + + +def _outer_x(part: dict[str, Any] | None) -> float | None: + if part is None: + return None + center = _as_vec3(part.get("center")) + rx = _as_float(part.get("rx_m")) + if center is None or rx is None: + return None + return abs(center[0]) + rx + + +def _inner_x(part: dict[str, Any] | None) -> float | None: + if part is None: + return None + center = _as_vec3(part.get("center")) + rx = _as_float(part.get("rx_m")) + if center is None or rx is None: + return None + return abs(center[0]) - rx + + +def _ry_over_rx(part: dict[str, Any] | None) -> float | None: + if part is None: + return None + rx = _as_float(part.get("rx_m")) + ry = _as_float(part.get("ry_m")) + if rx is None or ry is None or rx <= 0: + return None + return ry / rx + + +def build_hip_glute_metrics( + report: ProportionReport, + recipe: Any | None = None, +) -> HipGluteMetrics: + """Compute hip_glute_metrics from landmarks_xyz (+ optional RECIPE ovals).""" + parts = _recipe_parts(recipe) + hip = _find_part(parts, "RECIPE_torso_oval_hip") + pelvis = _find_part(parts, "RECIPE_pelvis_oval") + glute = _find_part(parts, "RECIPE_glute_soft_l") or _find_part(parts, "RECIPE_glute_soft_r") + hip_soft = _find_part(parts, "RECIPE_hip_soft_l") or _find_part(parts, "RECIPE_hip_soft_r") + + glute_outer = _outer_x(glute) + hip_soft_outer = _outer_x(hip_soft) + outer_delta: float | None = None + if glute_outer is not None and hip_soft_outer is not None: + outer_delta = hip_soft_outer - glute_outer + + hip_rear = _surface_y(hip, rear=True) + glute_rear = _surface_y(glute, rear=True) + pelvis_front = _surface_y(pelvis, rear=False) + glute_front = _surface_y(glute, rear=False) + + glute_cy: float | None = None + glute_ry = _as_float(glute.get("ry_m")) if glute is not None else None + if glute is not None: + gc = _as_vec3(glute.get("center")) + if gc is not None: + glute_cy = gc[1] + + lms = report.landmarks_xyz + y_fields: dict[str, float | None] = {} + for lid in HIP_GLUTE_COMPARE_ROLES: + lm = lms.get(lid) + y_fields[lid] = _as_float(lm.y_m) if lm is not None else None + + # Overlay measured center Y first, then recompute surface pride/past with +ry + # (plan slice B / 0133 lesson — do not leave RECIPE-derived past stale). + measured_seat = y_fields.get("glute_bottom_l") + if measured_seat is None: + measured_seat = y_fields.get("glute_bottom_r") + if measured_seat is None: + measured_seat = y_fields.get("glute_top_seam") + if measured_seat is not None: + glute_cy = measured_seat + if glute_ry is not None: + glute_front = glute_cy - glute_ry + glute_rear = glute_cy + glute_ry + + rear_past: float | None = None + if glute_rear is not None and hip_rear is not None: + rear_past = glute_rear - hip_rear + pelvis_vs_glute: float | None = None + if pelvis_front is not None and glute_front is not None: + pelvis_vs_glute = pelvis_front - glute_front + + thigh_l = lms.get("thigh_medial_l") + thigh_r = lms.get("thigh_medial_r") + xl = _as_float(thigh_l.x_m) if thigh_l is not None else None + xr = _as_float(thigh_r.x_m) if thigh_r is not None else None + thigh_gap: float | None = None + if xl is not None and xr is not None: + thigh_gap = abs(xr - xl) + + return HipGluteMetrics( + glute_outer_vs_hip_soft_outer_m=outer_delta, + glute_rear_past_hip_oval_m=rear_past, + pelvis_front_vs_glute_front_m=pelvis_vs_glute, + glute_seat_y_m=glute_cy, + glute_ry_over_rx=_ry_over_rx(glute), + hip_soft_ry_over_rx=_ry_over_rx(hip_soft), + thigh_gap_m=thigh_gap, + y_m=y_fields, + ) + + +def _measured_coord(lm: LandmarkXYZ | None) -> HipGluteCompareCoord | None: + if lm is None: + return None + x_m = _as_float(lm.x_m) + y_m = _as_float(lm.y_m) + z_m = _as_float(lm.z_m) + if x_m is None and y_m is None and z_m is None: + return None + return HipGluteCompareCoord( + x_m=x_m, + y_m=y_m, + z_m=z_m, + confidence=float(lm.confidence), + sources=list(lm.sources), + ) + + +def _delta_mm( + measured: HipGluteCompareCoord | None, + recipe: dict[str, Any] | None, +) -> HipGluteCompareDelta | None: + if measured is None or recipe is None: + return None + center = _as_vec3(recipe.get("center")) + if center is None: + return None + mx = _as_float(measured.x_m) + my = _as_float(measured.y_m) + mz = _as_float(measured.z_m) + dx = (mx - center[0]) * 1000.0 if mx is not None else None + dy = (my - center[1]) * 1000.0 if my is not None else None + dz = (mz - center[2]) * 1000.0 if mz is not None else None + if dx is None and dy is None and dz is None: + return None + return HipGluteCompareDelta(x=dx, y=dy, z=dz) + + +def _suggest( + measured: HipGluteCompareCoord | None, + recipe: dict[str, Any] | None, + delta: HipGluteCompareDelta | None, + role_id: str, +) -> Literal["skip", "hold_priors", "soft_adjust", "session_hip_glute", "remake_0111"]: + if measured is None: + return "skip" + if recipe is None or recipe.get("center") is None: + return "skip" + if role_id not in _SOFT_ADJUST_IDS: + return "hold_priors" + if delta is None: + return "hold_priors" + vals = [abs(v) for v in (delta.x, delta.y, delta.z) if v is not None] + if not vals: + return "hold_priors" + if max(vals) < 1.0: + return "hold_priors" + return "soft_adjust" + + +def _load_scene_dump(path: Path) -> list[dict[str, Any]]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ProportionError( + f"cannot load scene dump: {path}: {exc}", + code="hip_glute_compare_failed", + details={"path": str(path)}, + ) from exc + if not isinstance(raw, dict) or "parts" not in raw: + raise ProportionError( + f"scene dump must be an object with parts: {path}", + code="hip_glute_compare_failed", + details={"path": str(path)}, + ) + parts = raw.get("parts") + if not isinstance(parts, list): + raise ProportionError( + f"scene dump parts must be a list: {path}", + code="hip_glute_compare_failed", + details={"path": str(path)}, + ) + out: list[dict[str, Any]] = [] + for i, part in enumerate(parts): + if not isinstance(part, dict) or "name" not in part: + raise ProportionError( + f"scene dump part {i} must be an object with name", + code="hip_glute_compare_failed", + details={"path": str(path), "index": i}, + ) + name = str(part.get("name") or "") + if name in _KNOWN_DUMP_NAMES: + has_center = _as_vec3(part.get("center")) is not None + has_caps = _as_vec3(part.get("p0")) is not None and _as_vec3(part.get("p1")) is not None + if not has_center and not has_caps: + raise ProportionError( + f"scene dump part {name!r} missing center or p0/p1", + code="hip_glute_compare_failed", + details={"path": str(path), "index": i, "name": name}, + ) + out.append(part) + return out + + +def _has_finite_hip_glute(lms: dict[str, LandmarkXYZ]) -> bool: + for lid in HIP_GLUTE_COMPARE_ROLES: + lm = lms.get(lid) + if lm is None: + continue + if any(_as_float(v) is not None for v in (lm.x_m, lm.y_m, lm.z_m)): + return True + return False + + +def run_blockout_hip_glute_compare( + report: Path | str, + recipe: Path | str, + out: Path | str, + *, + scene_dump: Path | str | None = None, + force: bool = False, +) -> dict[str, Any]: + """Compare photo landmarks vs RECIPE vs optional live scene dump.""" + report_path = Path(report) + recipe_path = Path(recipe) + out_dir = Path(out) + dest = out_dir / HIP_GLUTE_COMPARE_JSON + if dest.exists() and not force: + raise ProportionError( + f"hip/glute compare exists: {dest} (pass --force)", + code="hip_glute_compare_failed", + details={"path": str(dest)}, + ) + + rep = load_report(report_path) + pkg = load_blockout_recipe(recipe_path) + parts = list(pkg.parts) + live_parts: list[dict[str, Any]] | None = None + if scene_dump is not None: + live_parts = _load_scene_dump(Path(scene_dump)) + + messages: list[str] = [ + "HIP_GLUTE_COMPARE_HONESTY — authoring QA only; not mesh or print success", + ] + metrics = build_hip_glute_metrics(rep, recipe=pkg) + short_outer = ( + metrics.glute_outer_vs_hip_soft_outer_m is not None + and metrics.glute_outer_vs_hip_soft_outer_m >= GLUTE_SHORT_OF_HIP_SOFT_M + ) + if short_outer: + messages.append( + f"glute_short_of_hip_soft_outer={metrics.glute_outer_vs_hip_soft_outer_m:.4f}" + ) + pelvis_behind = ( + metrics.pelvis_front_vs_glute_front_m is not None + and metrics.pelvis_front_vs_glute_front_m >= PELVIS_BEHIND_GLUTE_M + ) + if pelvis_behind: + messages.append(f"pelvis_front_behind_glute_m={metrics.pelvis_front_vs_glute_front_m:.4f}") + thigh_wide = metrics.thigh_gap_m is not None and metrics.thigh_gap_m > THIGH_GAP_WIDE_M + if thigh_wide: + messages.append(f"thigh_gap_wide_m={metrics.thigh_gap_m:.4f}") + + glute_l = _find_part(parts, "RECIPE_glute_soft_l") + glute_r = _find_part(parts, "RECIPE_glute_soft_r") + inner_l = _inner_x(glute_l) + inner_r = _inner_x(glute_r) + midline_gap = False + if inner_l is not None and inner_r is not None: + midline_gap = min(inner_l, inner_r) > 0.0 + elif inner_l is not None: + midline_gap = inner_l > 0.0 + elif inner_r is not None: + midline_gap = inner_r > 0.0 + if midline_gap: + messages.append("glute_midline_gap") + + lms = rep.landmarks_xyz + crotch = lms.get("crotch_pubic") + crotch_z = _as_float(crotch.z_m) if crotch is not None else None + + roles: list[HipGluteCompareRole] = [] + for lid in HIP_GLUTE_COMPARE_ROLES: + measured = _measured_coord(lms.get(lid)) + rec = extract_recipe_hip_glute_part(parts, lid) + live = extract_recipe_hip_glute_part(live_parts, lid) if live_parts is not None else None + delta = _delta_mm(measured, rec) + form: list[str] = [] + if measured is None: + form.append("missing_id") + if short_outer and lid.startswith("glute_outer_"): + form.append("glute_short_of_hip_soft_outer") + if pelvis_behind and lid in ( + "asis_l", + "asis_r", + "crotch_pubic", + "glute_front", + "pelvis", + ): + form.append("pelvis_front_behind_glute") + if thigh_wide and lid.startswith("thigh_medial_"): + form.append("thigh_gap_wide") + if midline_gap and lid in ("glute_cleft", "glute_peak_l", "glute_peak_r"): + form.append("glute_midline_gap") + if lid.startswith("groin_fold_") and measured is not None and crotch_z is not None: + gz = _as_float(measured.z_m) + if gz is not None and abs(gz - crotch_z) >= GROIN_DISCONNECT_M: + form.append("groin_disconnected") + conf = 0.0 + if measured is not None and measured.confidence is not None: + conf = float(measured.confidence) + elif rec is not None: + conf = 0.5 + roles.append( + HipGluteCompareRole( + id=lid, + measured=measured, + recipe=rec, + live=live, + delta_mm=delta, + knob=_KNOB.get(lid), + form_read=form, + confidence=conf, + suggested=_suggest(measured, rec, delta, lid), + ) + ) + + out_dir.mkdir(parents=True, exist_ok=True) + package = HipGluteComparePackage( + ok=True, + roles=roles, + messages=messages, + hip_glute_metrics=metrics, + package_path=str(dest), + ) + dest.write_text(package.model_dump_json(indent=2) + "\n", encoding="utf-8") + sidecar = out_dir / HIP_GLUTE_METRICS_JSON + if _has_finite_hip_glute(lms): + sidecar.write_text(metrics.model_dump_json(indent=2) + "\n", encoding="utf-8") + elif sidecar.exists(): + sidecar.unlink() + return package.model_dump(mode="json") diff --git a/src/meshops/proportion/honesty.py b/src/meshops/proportion/honesty.py index e68c3d6..e5f78ba 100644 --- a/src/meshops/proportion/honesty.py +++ b/src/meshops/proportion/honesty.py @@ -22,6 +22,7 @@ FEEDBACK_HONESTY = "proportion_blockout_feedback_not_mesh_or_print_success" FACE_COMPARE_HONESTY = "proportion_face_compare_not_mesh_or_print_success" TORSO_COMPARE_HONESTY = "proportion_torso_compare_not_mesh_or_print_success" +HIP_GLUTE_COMPARE_HONESTY = "proportion_hip_glute_compare_not_mesh_or_print_success" TEMPLATE_HONESTY = "proportion_body_template_not_mesh_or_print_success" CONSTRAINT_HONESTY = "proportion_blockout_constraints_not_mesh_or_print_success" OPTIMIZE_HONESTY = "proportion_blockout_optimize_not_mesh_or_print_success" diff --git a/src/meshops/proportion/template.py b/src/meshops/proportion/template.py index f28d55a..1daef14 100644 --- a/src/meshops/proportion/template.py +++ b/src/meshops/proportion/template.py @@ -78,6 +78,13 @@ "sternum_mid", "costal_l", "costal_r", + # 0126 hip/glute/groin form-read (front) + "asis_l", + "asis_r", + "groin_fold_l", + "groin_fold_r", + "thigh_medial_l", + "thigh_medial_r", ) # Left profile: depth pairs + optional heel/breast hang. No toe_l/r (B1 — front only). @@ -121,6 +128,10 @@ "sternum_mid", "scap_inferior_l", "mid_back_l", + # 0126 hip/glute Y / pride (never invent from front-only) + "glute_bottom_l", + "glute_top_seam", + "psis_l", ) # Top-level edge_pairs stubs (sibling of views) — fill [[x0,y0],[x1,y1]]. @@ -170,6 +181,14 @@ "scap_medial_r", "mid_back_l", "mid_back_r", + # 0126 hip/glute form-read (back — Package A currently empty here) + "psis_l", + "psis_r", + "glute_outer_l", + "glute_outer_r", + "glute_bottom_l", + "glute_bottom_r", + "glute_top_seam", ) # Top-down plan view (0030) — breast/glute soft-spacing primary vocabulary. diff --git a/tests/test_breast_costume_docs.py b/tests/test_breast_costume_docs.py index 191d927..000ffca 100644 --- a/tests/test_breast_costume_docs.py +++ b/tests/test_breast_costume_docs.py @@ -79,7 +79,7 @@ def test_t5_breast_tear_0067_hold() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_breast_costume_cli_command() -> None: diff --git a/tests/test_fist_gesture_docs.py b/tests/test_fist_gesture_docs.py index 2450cb1..2a024a7 100644 --- a/tests/test_fist_gesture_docs.py +++ b/tests/test_fist_gesture_docs.py @@ -81,7 +81,7 @@ def test_t5_r_scales_0088_hold() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_fist_cli_command() -> None: diff --git a/tests/test_foot_stack_docs.py b/tests/test_foot_stack_docs.py index 2e60bcf..871044a 100644 --- a/tests/test_foot_stack_docs.py +++ b/tests/test_foot_stack_docs.py @@ -83,7 +83,7 @@ def test_t5_foot_0108_nest_tip_hold() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_foot_cli_command() -> None: diff --git a/tests/test_hip_soft_docs.py b/tests/test_hip_soft_docs.py index b5f2fd7..5ec1fc4 100644 --- a/tests/test_hip_soft_docs.py +++ b/tests/test_hip_soft_docs.py @@ -80,13 +80,14 @@ def test_t5_hip_rx_scale_hold() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_hip_cli_command() -> None: - """T7: no blockout-hip / def hip_soft_scale / def hip_scale / def hip_michelin.""" + """T7: no hip_soft skill CLI (0126 hip-glute-compare is a different verb).""" cli_text = (_REPO / "src/meshops/cli.py").read_text(encoding="utf-8") - assert "blockout-hip" not in cli_text + assert "blockout-hip-soft" not in cli_text + assert 'command("blockout-hip")' not in cli_text assert "def hip_soft_scale" not in cli_text assert "def hip_scale" not in cli_text assert "def hip_michelin" not in cli_text diff --git a/tests/test_limb_soft_scale_docs.py b/tests/test_limb_soft_scale_docs.py index 900aa3f..66a74c0 100644 --- a/tests/test_limb_soft_scale_docs.py +++ b/tests/test_limb_soft_scale_docs.py @@ -83,7 +83,7 @@ def test_t5_shaft_0107_hold() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_limb_scale_cli_command() -> None: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 426b277..c88f60a 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -76,13 +76,14 @@ async def _body() -> None: assert "mesh_proportion_blockout_open_setup" in names assert "mesh_proportion_blockout_face_compare" in names assert "mesh_proportion_blockout_torso_compare" in names - assert len(names) == 49 + assert "mesh_proportion_blockout_hip_glute_compare" in names + assert len(names) == 50 _run(_body()) def test_mcp__proportion_tools_in_catalog() -> None: - """Explicit 0110 catalog freeze: proportion tools + open-setup; len == 49.""" + """Explicit 0110 catalog freeze: proportion tools + open-setup; len == 50.""" async def _body() -> None: server = build_server() @@ -113,30 +114,32 @@ async def _body() -> None: "mesh_proportion_blockout_open_setup", "mesh_proportion_blockout_face_compare", "mesh_proportion_blockout_torso_compare", + "mesh_proportion_blockout_hip_glute_compare", ): assert n in names - assert len(names) == 49 + assert len(names) == 50 assert names >= TOOL_NAMES - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 _run(_body()) def test_mcp__t10_t11_join_ready_and_catalog_47() -> None: - """T10/T11: catalog 49; emit-setup/fuse-plan/open-setup; recipe join_ready; feedback tool.""" + """T10/T11: catalog 50; emit-setup/fuse-plan/open-setup; recipe join_ready; feedback tool.""" async def _body() -> None: server = build_server() async with Client(server) as client: listed = await client.list_tools() by_name = {t.name: t for t in listed.tools} - assert len(by_name) == 49 + assert len(by_name) == 50 assert "mesh_proportion_blockout_emit_setup" in by_name assert "mesh_proportion_blockout_fuse_plan" in by_name assert "mesh_proportion_blockout_feedback" in by_name assert "mesh_proportion_blockout_open_setup" in by_name assert "mesh_proportion_blockout_face_compare" in by_name assert "mesh_proportion_blockout_torso_compare" in by_name + assert "mesh_proportion_blockout_hip_glute_compare" in by_name recipe_tool = by_name["mesh_proportion_blockout_recipe"] schema = ( getattr(recipe_tool, "input_schema", None) diff --git a/tests/test_michelin_cap_docs.py b/tests/test_michelin_cap_docs.py index b572362..422d949 100644 --- a/tests/test_michelin_cap_docs.py +++ b/tests/test_michelin_cap_docs.py @@ -95,7 +95,7 @@ def test_t5_packs_cap_only_on_deltoid_soft() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_michelin_cli_command() -> None: diff --git a/tests/test_one_character_loomis_docs.py b/tests/test_one_character_loomis_docs.py index 164f4d4..4aade13 100644 --- a/tests/test_one_character_loomis_docs.py +++ b/tests/test_one_character_loomis_docs.py @@ -87,7 +87,7 @@ def test_t5_lip_cheek_0102_hold() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_loomis_cli_command() -> None: diff --git a/tests/test_proportion_anatomy_profile.py b/tests/test_proportion_anatomy_profile.py index ac53d18..69b8632 100644 --- a/tests/test_proportion_anatomy_profile.py +++ b/tests/test_proportion_anatomy_profile.py @@ -467,7 +467,7 @@ def test_mcp__anatomy_profiles_catalog_and_recipe_params() -> None: from meshops.mcp.server import TOOL_NAMES, build_server assert "mesh_proportion_anatomy_profiles" in TOOL_NAMES - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 async def _body() -> None: server = build_server() @@ -475,7 +475,7 @@ async def _body() -> None: listed = await client.list_tools() names = {t.name for t in listed.tools} assert "mesh_proportion_anatomy_profiles" in names - assert len(names) == 49 + assert len(names) == 50 recipe = next(t for t in listed.tools if t.name == "mesh_proportion_blockout_recipe") raw_schema: object | None = getattr(recipe, "input_schema", None) if raw_schema is None: diff --git a/tests/test_proportion_arm_elbow_hang.py b/tests/test_proportion_arm_elbow_hang.py index e1d1321..eba76bc 100644 --- a/tests/test_proportion_arm_elbow_hang.py +++ b/tests/test_proportion_arm_elbow_hang.py @@ -480,7 +480,7 @@ def test_t9_n_parts_schema_mcp() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert SKELETON_SCHEMA_VERSION == "1.0.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t10_product_like_meters() -> None: diff --git a/tests/test_proportion_breast_chest_contact.py b/tests/test_proportion_breast_chest_contact.py index dea71d1..5e2bf3b 100644 --- a/tests/test_proportion_breast_chest_contact.py +++ b/tests/test_proportion_breast_chest_contact.py @@ -523,7 +523,7 @@ def test_t8_n_parts_schema_mcp() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t9_dual_y_equal_neighbors_hold(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_proportion_breast_hang.py b/tests/test_proportion_breast_hang.py index 38e2c46..c5283ff 100644 --- a/tests/test_proportion_breast_hang.py +++ b/tests/test_proportion_breast_hang.py @@ -793,7 +793,7 @@ def test_t12_schema_stays_1_4_0() -> None: assert RECIPE_SCHEMA_VERSION == "1.4.0" pkg = build_blockout_recipe(_report_soft_cs(), limbs=False, breast_tilt_deg=20.0) assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t13_messages_drop_anchor_chest_ref() -> None: diff --git a/tests/test_proportion_breast_hang_after_shoulder.py b/tests/test_proportion_breast_hang_after_shoulder.py index 8ee6c78..b491315 100644 --- a/tests/test_proportion_breast_hang_after_shoulder.py +++ b/tests/test_proportion_breast_hang_after_shoulder.py @@ -482,7 +482,7 @@ def test_t8_n_parts_schema_mcp() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t9_dual_y_equal_neighbors_hold(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_proportion_breast_lower_pole.py b/tests/test_proportion_breast_lower_pole.py index 4918379..3635e1b 100644 --- a/tests/test_proportion_breast_lower_pole.py +++ b/tests/test_proportion_breast_lower_pole.py @@ -592,7 +592,7 @@ def test_t14_schema_and_mcp_catalog() -> None: assert RECIPE_SCHEMA_VERSION == "1.4.0" pkg = build_blockout_recipe(_report_soft_cs(), limbs=False, breast_tilt_deg=20.0) assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t15_hang_suite_smoke_still_works() -> None: diff --git a/tests/test_proportion_calf_shaft_form.py b/tests/test_proportion_calf_shaft_form.py index 9ae8b0e..cfcc9db 100644 --- a/tests/test_proportion_calf_shaft_form.py +++ b/tests/test_proportion_calf_shaft_form.py @@ -412,7 +412,7 @@ def test_t7_n_parts_schema_mcp() -> None: ) assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t8_product_path_constraints() -> None: diff --git a/tests/test_proportion_calf_split_resync.py b/tests/test_proportion_calf_split_resync.py index c1300c0..1510b6f 100644 --- a/tests/test_proportion_calf_split_resync.py +++ b/tests/test_proportion_calf_split_resync.py @@ -489,7 +489,7 @@ def test_t6_schema_catalog_n_parts() -> None: **_product_flags(), # type: ignore[arg-type] ) assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 assert len(pkg.parts) == 131 diff --git a/tests/test_proportion_deltoid_anti_michelin_plus.py b/tests/test_proportion_deltoid_anti_michelin_plus.py index ded2ee9..bfcb5a6 100644 --- a/tests/test_proportion_deltoid_anti_michelin_plus.py +++ b/tests/test_proportion_deltoid_anti_michelin_plus.py @@ -242,7 +242,7 @@ def test_t9_product_n_parts_131_schema_mcp47() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t10_all_already_exports_delt_consts() -> None: diff --git a/tests/test_proportion_deltoid_michelin_cap.py b/tests/test_proportion_deltoid_michelin_cap.py index 21fc770..d4fd3b4 100644 --- a/tests/test_proportion_deltoid_michelin_cap.py +++ b/tests/test_proportion_deltoid_michelin_cap.py @@ -159,7 +159,7 @@ def test_t6_product_class_unclamped_meters() -> None: def test_t7_mcp47_schema_140() -> None: """T7: MCP catalog 47; recipe schema 1.4.0.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 assert RECIPE_SCHEMA_VERSION == "1.4.0" diff --git a/tests/test_proportion_extremity_recipe.py b/tests/test_proportion_extremity_recipe.py index e711100..e6e81b3 100644 --- a/tests/test_proportion_extremity_recipe.py +++ b/tests/test_proportion_extremity_recipe.py @@ -608,14 +608,14 @@ def test_ext__mcp_schema_and_tool_count() -> None: from meshops.mcp import TOOL_NAMES from meshops.mcp.server import build_server - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 async def _body() -> None: server = build_server() async with Client(server) as client: listed = await client.list_tools() names = {t.name for t in listed.tools} - assert len(names) == 49 + assert len(names) == 50 assert names >= TOOL_NAMES tool = next(t for t in listed.tools if t.name == "mesh_proportion_blockout_recipe") schema = getattr(tool, "input_schema", None) or getattr(tool, "inputSchema", None) @@ -998,7 +998,7 @@ def test_ext__t8_mcp_catalog_stays_46() -> None: """T8: MCP catalog stays 46 (no new tool).""" from meshops.mcp import TOOL_NAMES - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_ext__build_foot_parts_existing_parts_calf_floor() -> None: diff --git a/tests/test_proportion_face_compare.py b/tests/test_proportion_face_compare.py index 3126333..b553e05 100644 --- a/tests/test_proportion_face_compare.py +++ b/tests/test_proportion_face_compare.py @@ -514,7 +514,7 @@ def test_d7_stdout_honesty(tmp_path: Path) -> None: def test_f1_mcp_catalog_48() -> None: """F1: TOOL_NAMES 48 and face-compare tool present.""" assert "mesh_proportion_blockout_face_compare" in TOOL_NAMES - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_f2_cli_contains_verb() -> None: diff --git a/tests/test_proportion_face_orbital_lip_defaults.py b/tests/test_proportion_face_orbital_lip_defaults.py index a0e38bb..83b8477 100644 --- a/tests/test_proportion_face_orbital_lip_defaults.py +++ b/tests/test_proportion_face_orbital_lip_defaults.py @@ -200,7 +200,7 @@ def test_t8_n_parts_schema_mcp() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t9_public_exports_no_new_name() -> None: diff --git a/tests/test_proportion_face_recipe.py b/tests/test_proportion_face_recipe.py index 5ea7648..c894662 100644 --- a/tests/test_proportion_face_recipe.py +++ b/tests/test_proportion_face_recipe.py @@ -503,14 +503,14 @@ def test_face__mcp_schema_properties_and_tool_count() -> None: from meshops.mcp import TOOL_NAMES from meshops.mcp.server import build_server - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 async def _body() -> None: server = build_server() async with Client(server) as client: listed = await client.list_tools() names = {t.name for t in listed.tools} - assert len(names) == 49 + assert len(names) == 50 assert names >= TOOL_NAMES tool = next(t for t in listed.tools if t.name == "mesh_proportion_blockout_recipe") schema = tool.input_schema diff --git a/tests/test_proportion_face_soft_consume.py b/tests/test_proportion_face_soft_consume.py index 237a409..9701629 100644 --- a/tests/test_proportion_face_soft_consume.py +++ b/tests/test_proportion_face_soft_consume.py @@ -109,4 +109,4 @@ def test_e5_front_only_y_stays_feature_plane() -> None: def test_e_mcp_catalog_48() -> None: - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 diff --git a/tests/test_proportion_foot_scale_plus.py b/tests/test_proportion_foot_scale_plus.py index b3e036f..a905bfe 100644 --- a/tests/test_proportion_foot_scale_plus.py +++ b/tests/test_proportion_foot_scale_plus.py @@ -475,7 +475,7 @@ def test_t9_n_parts_schema_mcp() -> None: pkg = _product_pkg() assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t10_product_path_constraints() -> None: diff --git a/tests/test_proportion_foot_sphere_stack_polish.py b/tests/test_proportion_foot_sphere_stack_polish.py index f237875..6e00a74 100644 --- a/tests/test_proportion_foot_sphere_stack_polish.py +++ b/tests/test_proportion_foot_sphere_stack_polish.py @@ -243,7 +243,7 @@ def test_t8_n_parts_schema_mcp47() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t9_all_exports_tip_ry() -> None: diff --git a/tests/test_proportion_foot_stack_hierarchy.py b/tests/test_proportion_foot_stack_hierarchy.py index 334f7f5..b136d27 100644 --- a/tests/test_proportion_foot_stack_hierarchy.py +++ b/tests/test_proportion_foot_stack_hierarchy.py @@ -451,7 +451,7 @@ def test_t7_n_parts_schema_mcp() -> None: pkg = _product_pkg() assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t8_product_path_constraints() -> None: diff --git a/tests/test_proportion_generic_michelin_cap.py b/tests/test_proportion_generic_michelin_cap.py index b920639..937c59d 100644 --- a/tests/test_proportion_generic_michelin_cap.py +++ b/tests/test_proportion_generic_michelin_cap.py @@ -205,7 +205,7 @@ def test_t6_packs_cap_only_on_deltoid_soft() -> None: def test_t7_mcp47_schema_140() -> None: """T7: MCP catalog 47; recipe schema 1.4.0.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 assert RECIPE_SCHEMA_VERSION == "1.4.0" diff --git a/tests/test_proportion_hand_digit_curl.py b/tests/test_proportion_hand_digit_curl.py index 84f48ef..f065327 100644 --- a/tests/test_proportion_hand_digit_curl.py +++ b/tests/test_proportion_hand_digit_curl.py @@ -501,7 +501,7 @@ def test_t11_n_parts_mcp_palm() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert SKELETON_SCHEMA_VERSION == "1.0.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t12_four_digits_curl() -> None: diff --git a/tests/test_proportion_hand_digit_taper.py b/tests/test_proportion_hand_digit_taper.py index 57acc37..4aa6915 100644 --- a/tests/test_proportion_hand_digit_taper.py +++ b/tests/test_proportion_hand_digit_taper.py @@ -468,7 +468,7 @@ def test_t9_surface_n_parts() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert SKELETON_SCHEMA_VERSION == "1.0.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t10_product_composition_const_driven() -> None: diff --git a/tests/test_proportion_head_face_hierarchy.py b/tests/test_proportion_head_face_hierarchy.py index 0d41578..204ee6d 100644 --- a/tests/test_proportion_head_face_hierarchy.py +++ b/tests/test_proportion_head_face_hierarchy.py @@ -540,7 +540,7 @@ def test_t8_n_parts_schema_mcp() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t9_jaw_bulge_and_chin_flush() -> None: diff --git a/tests/test_proportion_heel_ankle_hw_fixture.py b/tests/test_proportion_heel_ankle_hw_fixture.py index efc1840..2e79017 100644 --- a/tests/test_proportion_heel_ankle_hw_fixture.py +++ b/tests/test_proportion_heel_ankle_hw_fixture.py @@ -138,4 +138,4 @@ def test_t6_0097_hierarchy_held() -> None: def test_t7_schema_mcp_held() -> None: """T7: schema 1.4.0 / MCP 47 / no src emit change in this track.""" assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 diff --git a/tests/test_proportion_hip_glute_compare.py b/tests/test_proportion_hip_glute_compare.py new file mode 100644 index 0000000..6ea8827 --- /dev/null +++ b/tests/test_proportion_hip_glute_compare.py @@ -0,0 +1,640 @@ +"""Track 0126 — blockout-hip-glute-compare JSON / honesty / scene-dump (offline). + +Authoring QA only — HIP_GLUTE_COMPARE_HONESTY. Not mesh or print success. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from typer.testing import CliRunner + +from meshops.cli import app +from meshops.mcp.server import TOOL_NAMES +from meshops.proportion.blockout_recipe import RECIPE_ID, RECIPE_SCHEMA_VERSION +from meshops.proportion.errors import ProportionError +from meshops.proportion.hip_glute_compare import ( + HIP_GLUTE_COMPARE_ROLES, + HIP_GLUTE_COMPARE_SCHEMA_VERSION, + SUGGESTED_ACTIONS, + build_hip_glute_metrics, + extract_recipe_hip_glute_part, + run_blockout_hip_glute_compare, +) +from meshops.proportion.honesty import HIP_GLUTE_COMPARE_HONESTY, PROPORTION_HONESTY +from meshops.proportion.models import ( + PROPORTION_SCHEMA_VERSION, + LandmarkXYZ, + ProportionReport, + QualityFlags, +) + +runner = CliRunner() + +_SUGGESTED = frozenset({"skip", "hold_priors", "soft_adjust", "session_hip_glute", "remake_0111"}) + + +def _lm( + lid: str, + *, + x_m: float | None = None, + y_m: float | None = None, + z_m: float | None = None, + confidence: float = 0.9, +) -> LandmarkXYZ: + return LandmarkXYZ( + id=lid, + x_m=x_m, + y_m=y_m, + z_m=z_m, + x=None, + y=None, + z=None, + confidence=confidence, + sources=["front"], + ) + + +def _report( + landmarks: dict[str, LandmarkXYZ] | None = None, + *, + height_m: float = 1.72, +) -> ProportionReport: + return ProportionReport( + schema_version="1.2.0", + honesty=PROPORTION_HONESTY, + height_m=height_m, + landmarks_xyz=landmarks or {}, + quality=QualityFlags(), + ) + + +def _write_report(path: Path, report: ProportionReport) -> Path: + path.write_text( + json.dumps(report.model_dump(mode="json"), indent=2) + "\n", + encoding="utf-8", + ) + return path + + +def _ellipsoid( + name: str, + role: str, + center: list[float], + rx: float, + ry: float, + rz: float, +) -> dict[str, Any]: + return { + "name": name, + "role": role, + "kind": "ellipsoid", + "center": center, + "rx_m": rx, + "ry_m": ry, + "rz_m": rz, + "label": name, + } + + +def _capsule( + name: str, + role: str, + p0: list[float], + p1: list[float], + radius: float, +) -> dict[str, Any]: + return { + "name": name, + "role": role, + "kind": "capsule", + "center": None, + "rx_m": None, + "ry_m": None, + "rz_m": None, + "p0": p0, + "p1": p1, + "radius_m": radius, + "label": name, + } + + +def _recipe_doc(*, parts: list[dict[str, Any]]) -> dict[str, Any]: + return { + "schema_version": RECIPE_SCHEMA_VERSION, + "honesty": "proportion_blockout_recipe_not_mesh_or_print_success", + "recipe_id": RECIPE_ID, + "axis_notes": "Z up, soles=0, +X camera-right, +Y toward camera_left", + "height_m": 1.72, + "head_unit_m": 0.21018, + "parts": parts, + } + + +def _write_recipe(path: Path, doc: dict[str, Any]) -> Path: + path.write_text(json.dumps(doc, indent=2) + "\n", encoding="utf-8") + return path + + +def _productish_recipe(*, short_outer: bool = True, pelvis_behind: bool = True) -> dict[str, Any]: + hip_soft_rx = 0.07050 if short_outer else 0.050 + pelvis_cy = 0.03475 if pelvis_behind else -0.05 + return _recipe_doc( + parts=[ + _ellipsoid( + "RECIPE_torso_oval_hip", + "torso", + [0.0, 0.02935, 1.00723], + 0.1979, + 0.08895, + 0.11476, + ), + _ellipsoid( + "RECIPE_pelvis_oval", + "pelvis", + [0.0, pelvis_cy, 0.83324], + 0.2224, + 0.08339, + 0.07224, + ), + _ellipsoid( + "RECIPE_glute_soft_l", + "glute_soft", + [-0.13075, 0.045, 0.84184], + 0.0890, + 0.1212, + 0.08725, + ), + _ellipsoid( + "RECIPE_glute_soft_r", + "glute_soft", + [0.13075, 0.045, 0.84184], + 0.0890, + 0.1212, + 0.08725, + ), + _ellipsoid( + "RECIPE_hip_soft_l", + "limb_segment", + [-0.22244, 0.00846, 0.86420], + hip_soft_rx, + 0.04371, + 0.07050, + ), + _ellipsoid( + "RECIPE_hip_soft_r", + "limb_segment", + [0.22244, 0.00846, 0.86420], + hip_soft_rx, + 0.04371, + 0.07050, + ), + _capsule( + "RECIPE_limb_thigh_l", + "limb_segment", + [-0.13, 0.0, 0.84], + [-0.13, 0.0, 0.50], + 0.0613, + ), + _capsule( + "RECIPE_limb_thigh_r", + "limb_segment", + [0.13, 0.0, 0.84], + [0.13, 0.0, 0.50], + 0.0613, + ), + _capsule( + "RECIPE_thigh_taper_dist_l", + "limb_segment", + [-0.13, 0.0, 0.50], + [-0.13, 0.0, 0.42], + 0.04414, + ), + _capsule( + "RECIPE_thigh_taper_dist_r", + "limb_segment", + [0.13, 0.0, 0.50], + [0.13, 0.0, 0.42], + 0.04414, + ), + ], + ) + + +def test_b1_outer_delta_when_recipe() -> None: + """B1: recipe present → glute vs hip_soft outer finite (inventory-class ~0.073).""" + metrics = build_hip_glute_metrics(_report(), recipe=_productish_recipe()) + assert metrics is not None + assert metrics.glute_outer_vs_hip_soft_outer_m == pytest.approx(0.07319, abs=1e-4) + + +def test_b2_front_only_y_null() -> None: + """B2: front-only measured → y_m null on new hip/glute ids (no invent).""" + report = _report( + { + "asis_l": _lm("asis_l", x_m=-0.10, z_m=0.95), + "glute_bottom_l": _lm("glute_bottom_l", x_m=-0.13, z_m=0.84), + } + ) + metrics = build_hip_glute_metrics(report, recipe=None) + assert metrics is not None + assert metrics.y_m.get("asis_l") is None + assert metrics.y_m.get("glute_bottom_l") is None + + +def test_b3_missing_one_glute_bottom_no_invent() -> None: + """B3: missing one glute_bottom → no invent contralateral.""" + report = _report({"glute_bottom_l": _lm("glute_bottom_l", x_m=-0.13, y_m=0.08, z_m=0.84)}) + metrics = build_hip_glute_metrics(report, recipe=None) + assert metrics is not None + assert metrics.y_m.get("glute_bottom_l") == pytest.approx(0.08, abs=1e-9) + assert metrics.y_m.get("glute_bottom_r") is None + + +def test_b4_proportion_report_stay_1_2_0() -> None: + """B4: proportion report schema stay 1.2.0 (sidecar, not a 1.3.0 bump).""" + assert PROPORTION_SCHEMA_VERSION == "1.2.0" + report = _report() + assert report.schema_version == "1.2.0" + dumped = report.model_dump(mode="json") + assert "hip_glute_metrics" not in dumped + + +def test_b5_thigh_gap_none_unless_both_finite() -> None: + """B5 / B35: one of thigh_medial_l/r missing → thigh_gap_m is None.""" + report = _report({"thigh_medial_l": _lm("thigh_medial_l", x_m=-0.04, z_m=0.80)}) + metrics = build_hip_glute_metrics(report, recipe=_productish_recipe()) + assert metrics is not None + assert metrics.thigh_gap_m is None + both = _report( + { + "thigh_medial_l": _lm("thigh_medial_l", x_m=-0.04, z_m=0.80), + "thigh_medial_r": _lm("thigh_medial_r", x_m=0.05, z_m=0.80), + } + ) + both_metrics = build_hip_glute_metrics(both, recipe=None) + assert both_metrics is not None + assert both_metrics.thigh_gap_m == pytest.approx(0.09, abs=1e-9) + + +def test_c1_recipe_glute_extract() -> None: + """C1: recipe JSON extracts RECIPE_glute_soft_l center/rx/ry/rz.""" + snap = extract_recipe_hip_glute_part(_productish_recipe()["parts"], "glute_bottom_l") + assert snap is not None + assert snap["name"] == "RECIPE_glute_soft_l" + assert snap["center"] is not None + assert snap["center"][1] == pytest.approx(0.045, abs=1e-6) + assert snap["rx_m"] == pytest.approx(0.0890, abs=1e-6) + assert snap["ry_m"] == pytest.approx(0.1212, abs=1e-6) + assert snap["rz_m"] == pytest.approx(0.08725, abs=1e-6) + + +def test_b_measured_seat_y_recomputes_surface_past() -> None: + """Measured glute_bottom Y overlays seat then recomputes surface past (+ry).""" + report = _report({"glute_bottom_l": _lm("glute_bottom_l", x_m=-0.13, y_m=0.18, z_m=0.84)}) + metrics = build_hip_glute_metrics(report, recipe=_productish_recipe()) + assert metrics is not None + assert metrics.glute_seat_y_m == pytest.approx(0.18, abs=1e-9) + hip_rear = 0.02935 + 0.08895 + glute_rear = 0.18 + 0.1212 + assert metrics.glute_rear_past_hip_oval_m == pytest.approx(glute_rear - hip_rear, abs=1e-4) + pelvis_front = 0.03475 - 0.08339 + glute_front = 0.18 - 0.1212 + assert metrics.pelvis_front_vs_glute_front_m == pytest.approx( + pelvis_front - glute_front, abs=1e-4 + ) + + +def test_c2_missing_scene_dump_live_null(tmp_path: Path) -> None: + """C2: missing --scene-dump → live=null (not fail).""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_hip_glute_compare(report, recipe, tmp_path / "cmp", force=True) + assert payload["ok"] is True + for role in payload["roles"]: + assert role["live"] is None + + +def test_c2b_valid_dump_live_populated(tmp_path: Path) -> None: + """Valid --scene-dump overlays live recipe extract (not null).""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + dump = _write_recipe( + tmp_path / "dump.json", + _recipe_doc( + parts=[ + _ellipsoid( + "RECIPE_glute_soft_l", + "glute_soft", + [-0.12, 0.06, 0.85], + 0.08, + 0.11, + 0.08, + ) + ] + ), + ) + payload = run_blockout_hip_glute_compare( + report, + recipe, + tmp_path / "cmp", + scene_dump=dump, + force=True, + ) + glute = next(r for r in payload["roles"] if r["id"] == "glute_bottom_l") + assert glute["live"] is not None + assert glute["live"]["center"] is not None + assert glute["live"]["center"][1] == pytest.approx(0.06, abs=1e-9) + + +def test_c3_malformed_dump_fail_closed(tmp_path: Path) -> None: + """C3: malformed dump → ProportionError (fail-closed).""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + dump = tmp_path / "dump.json" + dump.write_text("{not-json", encoding="utf-8") + with pytest.raises(ProportionError): + run_blockout_hip_glute_compare( + report, + recipe, + tmp_path / "cmp", + scene_dump=dump, + force=True, + ) + assert not (tmp_path / "cmp" / "hip_glute_compare.json").is_file() + + +def test_c3b_named_part_without_geometry_fail_closed(tmp_path: Path) -> None: + """C3: named hip/glute RECIPE dump part without center/p0/p1 fails closed.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + dump = tmp_path / "dump.json" + dump.write_text( + json.dumps( + { + "schema_version": "1.0.0", + "source": "scene", + "parts": [{"name": "RECIPE_glute_soft_l"}], + } + ) + + "\n", + encoding="utf-8", + ) + with pytest.raises(ProportionError): + run_blockout_hip_glute_compare( + report, + recipe, + tmp_path / "cmp", + scene_dump=dump, + force=True, + ) + assert not (tmp_path / "cmp" / "hip_glute_compare.json").is_file() + + +def test_c4_thigh_capsule_extract_p0() -> None: + """C4 / B26: capsule thigh extract uses p0; rx_m stays null.""" + p0 = [-0.13, 0.0, 0.84] + p1 = [-0.13, 0.0, 0.50] + snap = extract_recipe_hip_glute_part( + [_capsule("RECIPE_limb_thigh_l", "limb_segment", p0, p1, 0.0613)], + "thigh_medial_l", + ) + assert snap is not None + assert snap["kind"] == "capsule" + assert snap["center"] is not None + assert snap["center"][1] == pytest.approx(p0[1], abs=1e-9) + assert snap["center"][2] == pytest.approx(p0[2], abs=1e-9) + assert snap["radius_m"] == pytest.approx(0.0613, abs=1e-9) + assert snap["rx_m"] is None + + +def test_d1_honesty_schema_region(tmp_path: Path) -> None: + """D1: payload honesty / schema 1.0.0 / region=hip_glute.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_hip_glute_compare(report, recipe, tmp_path / "cmp", force=True) + assert payload["honesty"] == HIP_GLUTE_COMPARE_HONESTY + assert payload["schema_version"] == HIP_GLUTE_COMPARE_SCHEMA_VERSION + assert payload["schema_version"] == "1.0.0" + assert payload["region"] == "hip_glute" + raw = json.loads((tmp_path / "cmp" / "hip_glute_compare.json").read_text(encoding="utf-8")) + assert raw["honesty"] == HIP_GLUTE_COMPARE_HONESTY + assert raw["region"] == "hip_glute" + + +def test_d2_suggested_closed_set(tmp_path: Path) -> None: + """D2: each role has suggested in the closed set.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_hip_glute_compare(report, recipe, tmp_path / "cmp", force=True) + assert SUGGESTED_ACTIONS == _SUGGESTED + ids = {r["id"] for r in payload["roles"]} + for lid in HIP_GLUTE_COMPARE_ROLES: + assert lid in ids + for role in payload["roles"]: + assert role["suggested"] in _SUGGESTED + + +def test_d3_signed_delta_mm(tmp_path: Path) -> None: + """D3: signed delta_mm when both measured+recipe finite.""" + report = _write_report( + tmp_path / "report.json", + _report( + { + "glute_bottom_l": _lm("glute_bottom_l", x_m=-0.12, y_m=0.08, z_m=0.84184), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_hip_glute_compare(report, recipe, tmp_path / "cmp", force=True) + glute = next(r for r in payload["roles"] if r["id"] == "glute_bottom_l") + assert glute["delta_mm"] is not None + assert glute["delta_mm"]["x"] == pytest.approx((-0.12 - (-0.13075)) * 1000.0, abs=1e-3) + + +def test_d3b_glute_outer_hold_priors_not_soft_adjust(tmp_path: Path) -> None: + """D9 / B23: glute_outer_* stay hold_priors even when measured X finite.""" + report = _write_report( + tmp_path / "report.json", + _report( + { + "glute_outer_l": _lm("glute_outer_l", x_m=-0.30, y_m=0.05, z_m=0.84), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_hip_glute_compare(report, recipe, tmp_path / "cmp", force=True) + outer = next(r for r in payload["roles"] if r["id"] == "glute_outer_l") + assert outer["measured"] is not None + assert outer["delta_mm"] is not None + assert outer["suggested"] == "hold_priors" + + +def test_d4_missing_id_skip_no_nan(tmp_path: Path) -> None: + """D4: missing id → suggested skip / missing_id — no NaN.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_hip_glute_compare(report, recipe, tmp_path / "cmp", force=True) + bottom = next(r for r in payload["roles"] if r["id"] == "glute_bottom_l") + assert bottom["measured"] is None + assert bottom["suggested"] == "skip" + assert "missing_id" in bottom["form_read"] + dumped = json.dumps(payload) + assert "NaN" not in dumped + assert "Infinity" not in dumped + + +def test_d5_glute_short_of_hip_soft_outer(tmp_path: Path) -> None: + """D5: synthetic hip_soft-glute outer >= 0.040 -> glute_short_of_hip_soft_outer.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe(short_outer=True)) + payload = run_blockout_hip_glute_compare(report, recipe, tmp_path / "cmp", force=True) + tokens: list[str] = [] + for role in payload["roles"]: + tokens.extend(role.get("form_read") or []) + assert "glute_short_of_hip_soft_outer" in tokens + + +def test_d6_cli_help_json_ok(tmp_path: Path) -> None: + """D6: CLI help / --json path; exit 0 on structural ok.""" + help_result = runner.invoke(app, ["proportion", "blockout-hip-glute-compare", "--help"]) + assert help_result.exit_code == 0 + assert "blockout-hip-glute-compare" in help_result.output + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + result = runner.invoke( + app, + [ + "proportion", + "blockout-hip-glute-compare", + "--report", + str(report), + "--recipe", + str(recipe), + "--out", + str(tmp_path / "cmp"), + "--force", + "--json", + ], + ) + assert result.exit_code == 0 + payload = json.loads(result.stdout) + assert payload["ok"] is True + assert payload["region"] == "hip_glute" + + +def test_d7_stdout_honesty(tmp_path: Path) -> None: + """D7: stdout honesty — compare is not mesh or print success.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + result = runner.invoke( + app, + [ + "proportion", + "blockout-hip-glute-compare", + "--report", + str(report), + "--recipe", + str(recipe), + "--out", + str(tmp_path / "cmp"), + "--force", + ], + ) + assert result.exit_code == 0 + assert HIP_GLUTE_COMPARE_HONESTY in result.output + assert "not mesh or print success" in result.output.lower() + assert "Difficulty §N6" not in result.output + + +def test_d8_pelvis_front_behind_glute(tmp_path: Path) -> None: + """D8: synthetic pelvis front behind glute front by ≥0.010 → token.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe( + tmp_path / "recipe.json", + _productish_recipe(pelvis_behind=True), + ) + payload = run_blockout_hip_glute_compare(report, recipe, tmp_path / "cmp", force=True) + tokens: list[str] = [] + for role in payload["roles"]: + tokens.extend(role.get("form_read") or []) + assert "pelvis_front_behind_glute" in tokens + + +def test_d9_glute_outer_hold_priors_alias() -> None: + """D9 lives as test_d3b (hold_priors when measured X finite).""" + assert "glute_outer_l" in HIP_GLUTE_COMPARE_ROLES + + +def test_f1_mcp_catalog_50() -> None: + """F1: TOOL_NAMES 50 and hip-glute-compare tool present.""" + assert "mesh_proportion_blockout_hip_glute_compare" in TOOL_NAMES + assert len(TOOL_NAMES) == 50 + + +def test_f2_cli_contains_verb() -> None: + """F2: src/meshops/cli.py contains blockout-hip-glute-compare.""" + cli = Path("src/meshops/cli.py").read_text(encoding="utf-8") + assert "blockout-hip-glute-compare" in cli + + +def test_sidecar_unlinked_when_no_finite_ids(tmp_path: Path) -> None: + """--force with no finite hip/glute ids must not leave a stale sidecar.""" + report_hit = _write_report( + tmp_path / "hit.json", + _report({"asis_l": _lm("asis_l", x_m=-0.10, z_m=0.95)}), + ) + report_miss = _write_report(tmp_path / "miss.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + out = tmp_path / "cmp" + run_blockout_hip_glute_compare(report_hit, recipe, out, force=True) + assert (out / "hip_glute_metrics.json").is_file() + run_blockout_hip_glute_compare(report_miss, recipe, out, force=True) + assert not (out / "hip_glute_metrics.json").is_file() + + +def test_sidecar_hip_glute_metrics_when_ids_present(tmp_path: Path) -> None: + """Compare writes hip_glute_metrics.json when at least one v1 id is finite.""" + report = _write_report( + tmp_path / "report.json", + _report({"asis_l": _lm("asis_l", x_m=-0.10, z_m=0.95)}), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + out = tmp_path / "cmp" + payload = run_blockout_hip_glute_compare(report, recipe, out, force=True) + sidecar = out / "hip_glute_metrics.json" + assert sidecar.is_file() + metrics = json.loads(sidecar.read_text(encoding="utf-8")) + assert metrics["glute_outer_vs_hip_soft_outer_m"] == pytest.approx(0.07319, abs=1e-4) + assert payload["hip_glute_metrics"]["glute_outer_vs_hip_soft_outer_m"] == pytest.approx( + 0.07319, abs=1e-4 + ) + + +def test_mcp_wrapper_calls_compare(tmp_path: Path) -> None: + """MCP adapter reaches the same compare engine.""" + from meshops.mcp.tools import mesh_proportion_blockout_hip_glute_compare + + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = mesh_proportion_blockout_hip_glute_compare( + tmp_path, + report=str(report), + recipe=str(recipe), + out=str(tmp_path / "cmp"), + force=True, + ) + assert payload["ok"] is True + assert payload["region"] == "hip_glute" + assert (tmp_path / "cmp" / "hip_glute_compare.json").is_file() + + +def test_f4_honesty_token() -> None: + """F4: HIP_GLUTE_COMPARE_HONESTY in honesty.py.""" + from meshops.proportion import honesty as honesty_mod + + assert HIP_GLUTE_COMPARE_HONESTY == "proportion_hip_glute_compare_not_mesh_or_print_success" + assert hasattr(honesty_mod, "HIP_GLUTE_COMPARE_HONESTY") diff --git a/tests/test_proportion_hip_glute_landmarks.py b/tests/test_proportion_hip_glute_landmarks.py new file mode 100644 index 0000000..c1504b3 --- /dev/null +++ b/tests/test_proportion_hip_glute_landmarks.py @@ -0,0 +1,206 @@ +"""Track 0126 — hip/glute/groin landmark ids / template blanks / fuse XYZ (offline). + +Authoring measurement only (HIP_GLUTE_COMPARE_HONESTY / CAPTURE_HONESTY). +Not mesh or print success. Schema report stay 1.2.0. MCP 50 at ship. +""" + +from __future__ import annotations + +from meshops.proportion.assist import KNOWN_LANDMARK_IDS +from meshops.proportion.fuse import DEPTH_PAIRS, fuse_xyz +from meshops.proportion.models import Landmark2D, ViewLandmarks +from meshops.proportion.template import ( + _BACK_LANDMARK_KEYS, + _FRONT_LANDMARK_KEYS, + _LEFT_LANDMARK_KEYS, +) + +_HIP_GLUTE_FRONT_IDS: tuple[str, ...] = ( + "asis_l", + "asis_r", + "groin_fold_l", + "groin_fold_r", + "thigh_medial_l", + "thigh_medial_r", +) + +_HIP_GLUTE_LEFT_IDS: tuple[str, ...] = ( + "glute_bottom_l", + "glute_top_seam", + "psis_l", +) + +_HIP_GLUTE_BACK_IDS: tuple[str, ...] = ( + "psis_l", + "psis_r", + "glute_outer_l", + "glute_outer_r", + "glute_bottom_l", + "glute_bottom_r", + "glute_top_seam", +) + +_HIP_GLUTE_NEW_IDS: tuple[str, ...] = ( + "asis_l", + "asis_r", + "psis_l", + "psis_r", + "glute_outer_l", + "glute_outer_r", + "glute_bottom_l", + "glute_bottom_r", + "glute_top_seam", + "groin_fold_l", + "groin_fold_r", + "thigh_medial_l", + "thigh_medial_r", +) + + +def _lm2(lid: str, x_px: float, y_px: float, *, w: int = 100, h: int = 200) -> Landmark2D: + return Landmark2D( + id=lid, + x_px=x_px, + y_px=y_px, + x_frac=x_px / w, + y_frac=y_px / h, + method="assist", + confidence=1.0, + ) + + +def test_a1_hip_glute_ids_in_known() -> None: + """A1: frozen v1 form-read ids are known (membership in, not ==).""" + for lid in _HIP_GLUTE_NEW_IDS: + assert lid in KNOWN_LANDMARK_IDS + + +def test_a2_template_blanks_front_left_back() -> None: + """A2: front blanks include v1 ids; left subset; back outer/bottom/psis/top_seam.""" + for lid in _HIP_GLUTE_FRONT_IDS: + assert lid in _FRONT_LANDMARK_KEYS + for lid in _HIP_GLUTE_LEFT_IDS: + assert lid in _LEFT_LANDMARK_KEYS + for lid in _HIP_GLUTE_BACK_IDS: + assert lid in _BACK_LANDMARK_KEYS + + +def test_a3_missing_id_no_invent_y() -> None: + """A3: missing hip/glute id skipped; front-only never invents Y.""" + front = ViewLandmarks( + view="front", + width_px=100, + height_px=200, + facing_direction="camera_front", + landmarks={ + "cranial_vertex": _lm2("cranial_vertex", 50.0, 10.0), + "sole": _lm2("sole", 50.0, 190.0), + "chin": _lm2("chin", 50.0, 40.0), + "asis_l": _lm2("asis_l", 35.0, 110.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front}, height_m=1.72) + assert "asis_l" in out + assert out["asis_l"].x_m is not None + assert out["asis_l"].z_m is not None + assert out["asis_l"].y is None + assert out["asis_l"].y_m is None + assert "glute_bottom_l" not in out + assert "psis_l" not in out + + +def test_a4_hip_glute_have_ids_hold() -> None: + """A4: 0013/0030 hip/glute DEPTH + trochanter + peak/cleft stay known.""" + assert "hip_front" in KNOWN_LANDMARK_IDS + assert "glute_back" in KNOWN_LANDMARK_IDS + assert "greater_trochanter" in KNOWN_LANDMARK_IDS + assert "glute_peak_l" in KNOWN_LANDMARK_IDS + assert "crotch_pubic" in KNOWN_LANDMARK_IDS + assert "hip_front" in _LEFT_LANDMARK_KEYS + assert "glute_back" in _LEFT_LANDMARK_KEYS + assert "greater_trochanter" in _FRONT_LANDMARK_KEYS + assert "crotch_pubic" in _FRONT_LANDMARK_KEYS + + +def test_a5_no_glute_outer_front_depth_pair() -> None: + """A5: do not add glute_outer_front/back DEPTH_PAIRS.""" + flat = {lid for triple in DEPTH_PAIRS for lid in triple} + assert "glute_outer_front" not in flat + assert "glute_outer_back" not in flat + assert "glute_outer_front" not in KNOWN_LANDMARK_IDS + + +def test_a6_sternum_and_eye_still_known() -> None: + """A6: 0125 sternum_mid and 0124 eye_l still in KNOWN.""" + assert "sternum_mid" in KNOWN_LANDMARK_IDS + assert "eye_l" in KNOWN_LANDMARK_IDS + assert "eye_l" in _FRONT_LANDMARK_KEYS + + +def test_a_back_view_sets_glute_outer_xz() -> None: + """Back view fills X/Z for glute_outer; never invents Y (B8).""" + front = ViewLandmarks( + view="front", + width_px=100, + height_px=200, + facing_direction="camera_front", + landmarks={ + "cranial_vertex": _lm2("cranial_vertex", 50.0, 10.0), + "sole": _lm2("sole", 50.0, 190.0), + "chin": _lm2("chin", 50.0, 40.0), + }, + ) + back = ViewLandmarks( + view="back", + width_px=100, + height_px=200, + facing_direction="camera_back", + landmarks={ + "cranial_vertex": _lm2("cranial_vertex", 50.0, 10.0), + "sole": _lm2("sole", 50.0, 190.0), + "midline_x": _lm2("midline_x", 50.0, 100.0), + "glute_outer_l": _lm2("glute_outer_l", 70.0, 120.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front, "back": back}, height_m=1.72) + assert "glute_outer_l" in out + assert out["glute_outer_l"].x is not None + assert out["glute_outer_l"].z is not None + assert out["glute_outer_l"].x_m is not None + assert out["glute_outer_l"].z_m is not None + assert out["glute_outer_l"].y is None + assert out["glute_outer_l"].y_m is None + # camera_back inverts X so larger image-x (right of back photo) → MeshOps -X + assert out["glute_outer_l"].x < 0.0 + + +def test_a_left_view_sets_hip_glute_y() -> None: + """Left-view same-id overlay sets Y; never invents from front-only.""" + front = ViewLandmarks( + view="front", + width_px=100, + height_px=200, + facing_direction="camera_front", + landmarks={ + "cranial_vertex": _lm2("cranial_vertex", 50.0, 10.0), + "sole": _lm2("sole", 50.0, 190.0), + "chin": _lm2("chin", 50.0, 40.0), + "asis_l": _lm2("asis_l", 35.0, 110.0), + }, + ) + left = ViewLandmarks( + view="left", + width_px=100, + height_px=200, + facing_direction="camera_left", + landmarks={ + "hip_front": _lm2("hip_front", 70.0, 110.0), + "hip_back": _lm2("hip_back", 30.0, 110.0), + "glute_bottom_l": _lm2("glute_bottom_l", 25.0, 125.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front, "left": left}, height_m=1.72) + assert out["glute_bottom_l"].y is not None + assert out["glute_bottom_l"].y_m is not None + assert out["asis_l"].y is None + assert out["asis_l"].y_m is None diff --git a/tests/test_proportion_hip_glute_soft_consume.py b/tests/test_proportion_hip_glute_soft_consume.py new file mode 100644 index 0000000..74f3bed --- /dev/null +++ b/tests/test_proportion_hip_glute_soft_consume.py @@ -0,0 +1,197 @@ +"""Track 0126 — soft new-id glute Y/Z placement (no 0106/0092/0068/0053/0070/0036 retune). + +Does not weaken 0106 T* / 0068 seat / 0092 plate / 0036 outer asserts. +Authoring only — not mesh or print success. +""" + +from __future__ import annotations + +import pytest + +from meshops.mcp.server import TOOL_NAMES +from meshops.proportion.blockout_recipe import ( + GLUTE_RX_LAT_FLOOR_FRAC_HIP_HW, + GLUTE_SEAT_BEYOND_REF_Y, + GLUTE_SEAT_Y_FLOOR_M, + GLUTE_SEAT_Z_DROP_FRAC_H, + HIP_SOFT_RY_FRAC_RX, + HIP_SOFT_RZ_FRAC_RX, + HIP_SOFT_Z_DROP_FRAC_H, + PELVIS_OVAL_RY_FRAC_HALF_HIP, + THIGH_DIST_SHAFT_SCALE, + TORSO_HIP_Y_REAR_BIAS_FRAC_RY, + TORSO_OVAL_RY_HIP_FRAC, + build_blockout_recipe, +) +from meshops.proportion.models import LandmarkXYZ +from meshops.proportion.skeleton import build_blockout_skeleton +from test_proportion_torso_anti_tire_plus import ( + _product_class_report, + _product_flags, + _template, +) + + +def _lm( + lid: str, + *, + x_m: float | None = None, + y_m: float | None = None, + z_m: float | None = None, +) -> LandmarkXYZ: + return LandmarkXYZ(id=lid, x_m=x_m, y_m=y_m, z_m=z_m) + + +def _emit(report, **flag_overrides: object): + skel = build_blockout_skeleton(report) + return build_blockout_recipe( + report, + skeleton=skel, + template_applied=_template(), + **_product_flags(**flag_overrides), # type: ignore[arg-type] + ) + + +def test_e1_measured_glute_y_survives_dual_lock() -> None: + """E1: measured glute_bottom_l Y → emitted glute center[1] tracks measured. + + B32/B33: measured value must survive seat write and dual lock max(cys). + """ + report = _product_class_report() + measured_y = 0.18 + report.landmarks_xyz["glute_bottom_l"] = _lm( + "glute_bottom_l", x_m=-0.13, y_m=measured_y, z_m=0.84 + ) + pkg = _emit(report) + glute_l = next(p for p in pkg.parts if p.name == "RECIPE_glute_soft_l") + assert glute_l.center is not None + assert float(glute_l.center[1]) == pytest.approx(measured_y, abs=1e-6) + assert any("measured glute y=" in m for m in pkg.messages) + + +def test_e2_absent_ids_prior_path() -> None: + """E2: absent glute_bottom/top_seam ids → glute cy matches 0068 prior path.""" + report = _product_class_report() + pkg = _emit(report) + glute_l = next(p for p in pkg.parts if p.name == "RECIPE_glute_soft_l") + assert glute_l.center is not None + prior_y = float(glute_l.center[1]) + # 0068 path: floor 0.045 then beyond-ref may raise a couple mm (product ~0.0467). + assert prior_y >= GLUTE_SEAT_Y_FLOOR_M - 1e-6 + assert prior_y == pytest.approx(GLUTE_SEAT_Y_FLOOR_M, abs=5e-3) + assert prior_y != pytest.approx(0.18, abs=1e-3) + assert not any("measured glute y=" in m for m in pkg.messages) + + +def test_e3_const_hold() -> None: + """E3: 0106/0092/0068/0053/0070/0036 hold — B34 hip ry is _FRAC.""" + assert HIP_SOFT_RY_FRAC_RX == 0.62 + assert HIP_SOFT_RZ_FRAC_RX == 1.00 + assert HIP_SOFT_Z_DROP_FRAC_H == 0.022 + assert TORSO_OVAL_RY_HIP_FRAC == 0.64 + assert TORSO_HIP_Y_REAR_BIAS_FRAC_RY == 0.33 + assert GLUTE_SEAT_Y_FLOOR_M == 0.045 + assert GLUTE_SEAT_BEYOND_REF_Y == 0.035 + assert GLUTE_SEAT_Z_DROP_FRAC_H == 0.035 + assert PELVIS_OVAL_RY_FRAC_HALF_HIP == 0.60 + assert THIGH_DIST_SHAFT_SCALE == 0.72 + assert GLUTE_RX_LAT_FLOOR_FRAC_HIP_HW == 0.40 + + +def test_e4_front_only_asis_no_glute_y() -> None: + """E4: front-only measured asis → no Y write on glute.""" + report = _product_class_report() + baseline = _emit(report) + report.landmarks_xyz["asis_l"] = _lm("asis_l", x_m=-0.10, z_m=0.95) + moved = _emit(report) + base_g = next(p for p in baseline.parts if p.name == "RECIPE_glute_soft_l") + new_g = next(p for p in moved.parts if p.name == "RECIPE_glute_soft_l") + assert base_g.center is not None and new_g.center is not None + assert float(new_g.center[1]) == pytest.approx(float(base_g.center[1]), abs=1e-9) + + +def test_e5_have_ids_do_not_change_seat() -> None: + """E5: hip_front/glute_back/greater_trochanter finite must not change seat emit.""" + report = _product_class_report() + baseline = _emit(report) + report.landmarks_xyz["hip_front"] = _lm("hip_front", x_m=0.0, y_m=-0.20, z_m=1.00) + report.landmarks_xyz["glute_back"] = _lm("glute_back", x_m=0.0, y_m=0.20, z_m=0.84) + report.landmarks_xyz["greater_trochanter"] = _lm( + "greater_trochanter", x_m=-0.22, y_m=0.01, z_m=0.86 + ) + moved = _emit(report) + assert GLUTE_SEAT_Y_FLOOR_M == 0.045 + assert HIP_SOFT_RY_FRAC_RX == 0.62 + base_g = next(p for p in baseline.parts if p.name == "RECIPE_glute_soft_l") + new_g = next(p for p in moved.parts if p.name == "RECIPE_glute_soft_l") + assert base_g.center is not None and new_g.center is not None + assert float(new_g.center[1]) == pytest.approx(float(base_g.center[1]), abs=1e-6) + base_h = next(p for p in baseline.parts if p.name == "RECIPE_hip_soft_l") + new_h = next(p for p in moved.parts if p.name == "RECIPE_hip_soft_l") + assert base_h.center is not None and new_h.center is not None + assert float(new_h.center[1]) == pytest.approx(float(base_h.center[1]), abs=1e-6) + + +def test_e6_measured_glute_outer_x_does_not_move_center_x() -> None: + """E6: measured glute_outer_l X must not change glute center[0] vs 0036 align.""" + report = _product_class_report() + baseline = _emit(report) + report.landmarks_xyz["glute_outer_l"] = _lm("glute_outer_l", x_m=-0.40, y_m=0.05, z_m=0.84) + moved = _emit(report) + base_g = next(p for p in baseline.parts if p.name == "RECIPE_glute_soft_l") + new_g = next(p for p in moved.parts if p.name == "RECIPE_glute_soft_l") + assert base_g.center is not None and new_g.center is not None + assert float(new_g.center[0]) == pytest.approx(float(base_g.center[0]), abs=1e-9) + + +def test_e7_one_side_measured_survives_dual_lock_max() -> None: + """E7: one-side measured Y smaller than contralateral prior survives dual lock.""" + report = _product_class_report() + baseline = _emit(report) + base_r = next(p for p in baseline.parts if p.name == "RECIPE_glute_soft_r") + assert base_r.center is not None + prior_r = float(base_r.center[1]) + measured_y = 0.02 + assert measured_y < prior_r + report.landmarks_xyz["glute_bottom_l"] = _lm( + "glute_bottom_l", x_m=-0.13, y_m=measured_y, z_m=0.84 + ) + pkg = _emit(report) + glute_l = next(p for p in pkg.parts if p.name == "RECIPE_glute_soft_l") + glute_r = next(p for p in pkg.parts if p.name == "RECIPE_glute_soft_r") + assert glute_l.center is not None and glute_r.center is not None + assert float(glute_l.center[1]) == pytest.approx(measured_y, abs=1e-6) + assert float(glute_r.center[1]) == pytest.approx(prior_r, abs=1e-6) + + +def test_e_measured_glute_z_after_dual_lock() -> None: + """Measured glute_bottom_l Z survives dual lock (B33).""" + report = _product_class_report() + measured_z = 0.90 + report.landmarks_xyz["glute_bottom_l"] = _lm( + "glute_bottom_l", x_m=-0.13, y_m=0.18, z_m=measured_z + ) + pkg = _emit(report) + glute_l = next(p for p in pkg.parts if p.name == "RECIPE_glute_soft_l") + assert glute_l.center is not None + assert float(glute_l.center[2]) == pytest.approx(measured_z, abs=1e-6) + assert any("measured glute z=" in m for m in pkg.messages) + + +def test_e_top_seam_y_both_sides() -> None: + """Measured glute_top_seam Y overlays both glute_soft sides after dual lock.""" + report = _product_class_report() + measured_y = 0.11 + report.landmarks_xyz["glute_top_seam"] = _lm( + "glute_top_seam", x_m=0.0, y_m=measured_y, z_m=0.84 + ) + pkg = _emit(report) + for side in ("l", "r"): + glute = next(p for p in pkg.parts if p.name == f"RECIPE_glute_soft_{side}") + assert glute.center is not None + assert float(glute.center[1]) == pytest.approx(measured_y, abs=1e-6) + assert any("glute_top_seam" in m for m in pkg.messages) + + +def test_e_mcp_catalog_50() -> None: + assert len(TOOL_NAMES) == 50 diff --git a/tests/test_proportion_hip_hierarchy.py b/tests/test_proportion_hip_hierarchy.py index 61a8d08..febedd8 100644 --- a/tests/test_proportion_hip_hierarchy.py +++ b/tests/test_proportion_hip_hierarchy.py @@ -490,7 +490,7 @@ def test_t9_product_n_parts_131_schema_mcp() -> None: ) assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 result = validate_constraints(pkg, report=report) by_id = {r.id: r for r in result.rules} assert "C_thigh_outer" in by_id diff --git a/tests/test_proportion_hip_soft_hierarchy_plus.py b/tests/test_proportion_hip_soft_hierarchy_plus.py index dcb634e..e1d3eb5 100644 --- a/tests/test_proportion_hip_soft_hierarchy_plus.py +++ b/tests/test_proportion_hip_soft_hierarchy_plus.py @@ -186,7 +186,7 @@ def test_t9_product_n_parts_131_schema_mcp47() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t10_all_already_exports_hip_soft_consts() -> None: diff --git a/tests/test_proportion_knee_bead_soften.py b/tests/test_proportion_knee_bead_soften.py index 5e68c6e..b447472 100644 --- a/tests/test_proportion_knee_bead_soften.py +++ b/tests/test_proportion_knee_bead_soften.py @@ -383,7 +383,7 @@ def test_t7_n_parts_schema_mcp() -> None: ) assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t8_product_path_constraints() -> None: diff --git a/tests/test_proportion_limb_shaft_form_plus.py b/tests/test_proportion_limb_shaft_form_plus.py index 0a879be..6068267 100644 --- a/tests/test_proportion_limb_shaft_form_plus.py +++ b/tests/test_proportion_limb_shaft_form_plus.py @@ -231,7 +231,7 @@ def test_t9_n_parts_schema_mcp47() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t10_all_already_exports_dist_scales() -> None: diff --git a/tests/test_proportion_mid_back_waist_integrate.py b/tests/test_proportion_mid_back_waist_integrate.py index d4fdc47..0a36a1e 100644 --- a/tests/test_proportion_mid_back_waist_integrate.py +++ b/tests/test_proportion_mid_back_waist_integrate.py @@ -580,7 +580,7 @@ def test_t9_product_n_parts_131_schema_mcp() -> None: ) assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 result = validate_constraints(pkg, report=report) by_id = {r.id: r for r in result.rules} assert "C_glute_outer" in by_id diff --git a/tests/test_proportion_neck_nape_setback.py b/tests/test_proportion_neck_nape_setback.py index b087c15..a3fc678 100644 --- a/tests/test_proportion_neck_nape_setback.py +++ b/tests/test_proportion_neck_nape_setback.py @@ -432,7 +432,7 @@ def test_t8_n_parts_schema_mcp() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t9_base_and_scm_follow_p0_neckline_stays() -> None: diff --git a/tests/test_proportion_setup_launch.py b/tests/test_proportion_setup_launch.py index 6176067..34c81eb 100644 --- a/tests/test_proportion_setup_launch.py +++ b/tests/test_proportion_setup_launch.py @@ -59,13 +59,13 @@ def test_t0_hygiene() -> None: server = (_REPO / "src/meshops/mcp/server.py").read_text(encoding="utf-8") assert "mesh_proportion_blockout_open_setup" in server mcp_test = (_REPO / "tests/test_mcp_server.py").read_text(encoding="utf-8") - assert "len(TOOL_NAMES) == 49" in mcp_test + assert "len(TOOL_NAMES) == 50" in mcp_test launch = (_REPO / "src/meshops/proportion/setup_launch.py").read_text(encoding="utf-8") assert "build_and_render" in launch assert "emit_bpy_script" not in launch assert "PARTS =" not in launch assert "mesh_proportion_blockout_open_setup" in TOOL_NAMES - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t1_file_abs_print(tmp_path: Path, fake_blender: Path) -> None: @@ -321,7 +321,7 @@ def test_t13_cli_json(tmp_path: Path, fake_blender: Path) -> None: def test_t14_mcp_catalog_47() -> None: """T14: mesh_proportion_blockout_open_setup in TOOL_NAMES; len == 48.""" assert "mesh_proportion_blockout_open_setup" in TOOL_NAMES - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t14b_mcp_wrapper(tmp_path: Path, fake_blender: Path) -> None: diff --git a/tests/test_proportion_skeleton.py b/tests/test_proportion_skeleton.py index 17762a4..312d85a 100644 --- a/tests/test_proportion_skeleton.py +++ b/tests/test_proportion_skeleton.py @@ -688,7 +688,7 @@ def test_skeleton__cli_depth_at_landmarks_file(tmp_path: Path) -> None: from meshops.mcp import TOOL_NAMES from meshops.mcp.tools import mesh_proportion_skeleton_build - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 assert "mesh_proportion_skeleton_build" in TOOL_NAMES lms = { diff --git a/tests/test_proportion_skeleton_hang_message.py b/tests/test_proportion_skeleton_hang_message.py index 6939de6..64416a0 100644 --- a/tests/test_proportion_skeleton_hang_message.py +++ b/tests/test_proportion_skeleton_hang_message.py @@ -208,7 +208,7 @@ def test_t6_schema_catalog() -> None: """T6: skeleton 1.0.0; recipe 1.4.0; MCP catalog 47.""" assert SKELETON_SCHEMA_VERSION == "1.0.0" assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_all_hold_no_new_name() -> None: diff --git a/tests/test_proportion_thigh_distal_taper_plus.py b/tests/test_proportion_thigh_distal_taper_plus.py index 9dcf1f1..7de35dd 100644 --- a/tests/test_proportion_thigh_distal_taper_plus.py +++ b/tests/test_proportion_thigh_distal_taper_plus.py @@ -400,7 +400,7 @@ def test_t8_n_parts_schema_mcp() -> None: ) assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t9_product_path_constraints() -> None: diff --git a/tests/test_proportion_torso_anti_tire_plus.py b/tests/test_proportion_torso_anti_tire_plus.py index 7f16eb4..42a7a65 100644 --- a/tests/test_proportion_torso_anti_tire_plus.py +++ b/tests/test_proportion_torso_anti_tire_plus.py @@ -475,7 +475,7 @@ def test_t9_product_n_parts_131_schema_mcp47() -> None: assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" assert pkg.schema_version == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 result = validate_constraints(pkg, report=report) by_id = {r.id: r for r in result.rules} assert "C_palm_ellipsoid" in by_id diff --git a/tests/test_proportion_torso_compare.py b/tests/test_proportion_torso_compare.py index d76d9bb..e2e0c5e 100644 --- a/tests/test_proportion_torso_compare.py +++ b/tests/test_proportion_torso_compare.py @@ -526,7 +526,7 @@ def test_d8_breast_disconnected(tmp_path: Path) -> None: def test_f1_mcp_catalog_49() -> None: """F1: TOOL_NAMES 49 and torso-compare tool present.""" assert "mesh_proportion_blockout_torso_compare" in TOOL_NAMES - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_f2_cli_contains_verb() -> None: diff --git a/tests/test_proportion_torso_continuous.py b/tests/test_proportion_torso_continuous.py index e4fa415..8181f34 100644 --- a/tests/test_proportion_torso_continuous.py +++ b/tests/test_proportion_torso_continuous.py @@ -461,7 +461,7 @@ def test_t9_product_n_parts_131_schema_mcp() -> None: pkg = build_blockout_recipe(report, skeleton=skel, **_product_flags()) # type: ignore[arg-type] assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 result = validate_constraints(pkg, report=report) by_id = {r.id: r for r in result.rules} assert "C_palm_ellipsoid" in by_id diff --git a/tests/test_proportion_torso_soft_consume.py b/tests/test_proportion_torso_soft_consume.py index 2d16aa4..b873c7d 100644 --- a/tests/test_proportion_torso_soft_consume.py +++ b/tests/test_proportion_torso_soft_consume.py @@ -135,4 +135,4 @@ def test_e_left_y_consumed_on_mid_back() -> None: def test_e_mcp_catalog_49() -> None: - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 diff --git a/tests/test_proportion_torso_thoracic_front.py b/tests/test_proportion_torso_thoracic_front.py index 1e6e67b..8c949a4 100644 --- a/tests/test_proportion_torso_thoracic_front.py +++ b/tests/test_proportion_torso_thoracic_front.py @@ -481,7 +481,7 @@ def test_t9_product_n_parts_131_schema_mcp() -> None: pkg = build_blockout_recipe(report, skeleton=skel, **_product_flags()) # type: ignore[arg-type] assert len(pkg.parts) == 131 assert RECIPE_SCHEMA_VERSION == "1.4.0" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 result = validate_constraints(pkg, report=report) by_id = {r.id: r for r in result.rules} assert "C_palm_ellipsoid" in by_id diff --git a/tests/test_proportion_validate_template_optional.py b/tests/test_proportion_validate_template_optional.py index 6c4e80c..0c36149 100644 --- a/tests/test_proportion_validate_template_optional.py +++ b/tests/test_proportion_validate_template_optional.py @@ -443,7 +443,7 @@ def test_t9_optimize_shares_helper(tmp_path: Path) -> None: def test_t10_mcp_catalog_hold() -> None: """T10: MCP catalog 47 after 0110.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t11_cli_help_skip_and_parent() -> None: diff --git a/tests/test_remake_policy_docs.py b/tests/test_remake_policy_docs.py index e90c649..d3dcbd1 100644 --- a/tests/test_remake_policy_docs.py +++ b/tests/test_remake_policy_docs.py @@ -99,7 +99,7 @@ def test_t5_refuse_build_and_render(tmp_path: Path, fake_blender: Path) -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_remake_cli_command() -> None: diff --git a/tests/test_sculpt_handoff_docs.py b/tests/test_sculpt_handoff_docs.py index 3d20c60..d298e01 100644 --- a/tests/test_sculpt_handoff_docs.py +++ b/tests/test_sculpt_handoff_docs.py @@ -78,7 +78,7 @@ def test_t5_fuse_honesty_token() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_sculpt_cli_command() -> None: diff --git a/tests/test_viewport_soft_hide_docs.py b/tests/test_viewport_soft_hide_docs.py index f7b7068..03d2c43 100644 --- a/tests/test_viewport_soft_hide_docs.py +++ b/tests/test_viewport_soft_hide_docs.py @@ -90,7 +90,7 @@ def test_t5_compact_cull_name_sets_hold() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 49 + assert len(TOOL_NAMES) == 50 def test_t7_no_hide_cli_command() -> None: