diff --git a/src/meshops/cli.py b/src/meshops/cli.py index 3c5fd6b..6a8f05f 100644 --- a/src/meshops/cli.py +++ b/src/meshops/cli.py @@ -122,7 +122,9 @@ "blockout-torso-compare photo vs RECIPE vs optional scene " "(0125; TORSO_COMPARE_HONESTY — N6); " "blockout-hip-glute-compare photo vs RECIPE vs optional scene " - "(0126; HIP_GLUTE_COMPARE_HONESTY — N6). " + "(0126; HIP_GLUTE_COMPARE_HONESTY — N6); " + "blockout-leg-foot-compare photo vs RECIPE vs optional scene " + "(0127; LEG_FOOT_COMPARE_HONESTY — N6). " "Optional: meshops[proportion] (Pillow)." ), add_completion=False, @@ -3486,5 +3488,71 @@ def proportion_blockout_hip_glute_compare_cmd( raise typer.Exit(0) +@proportion_app.command("blockout-leg-foot-compare") +def proportion_blockout_leg_foot_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 leg_foot_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 leg_foot_compare.json", + ), + json_out: bool = typer.Option(False, "--json", help="Emit machine result JSON"), +) -> None: + """Compare Package A leg/foot landmarks vs RECIPE vs optional live scene. + + Authoring QA only — not mesh or print success (LEG_FOOT_COMPARE_HONESTY). + """ + from meshops.proportion.errors import ProportionError + from meshops.proportion.honesty import LEG_FOOT_COMPARE_HONESTY + from meshops.proportion.leg_foot_compare import run_blockout_leg_foot_compare + + try: + payload = run_blockout_leg_foot_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-leg-foot-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: {LEG_FOOT_COMPARE_HONESTY}") + typer.echo("blockout-leg-foot-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 8156d41..89d13d6 100644 --- a/src/meshops/mcp/server.py +++ b/src/meshops/mcp/server.py @@ -74,6 +74,7 @@ "mesh_proportion_blockout_face_compare", "mesh_proportion_blockout_torso_compare", "mesh_proportion_blockout_hip_glute_compare", + "mesh_proportion_blockout_leg_foot_compare", } ) @@ -1021,4 +1022,26 @@ def mesh_proportion_blockout_hip_glute_compare( force=force, ) + @mcp.tool() + def mesh_proportion_blockout_leg_foot_compare( + report: str, + recipe: str, + out: str, + scene_dump: str | None = None, + force: bool = False, + ) -> dict[str, Any]: + """Compare Package A leg/foot landmarks vs RECIPE vs optional live scene dump. + + Authoring QA only — proportion_leg_foot_compare_not_mesh_or_print_success. + Not mesh or print success. Raises ProportionError on hard failures. + """ + return T.mesh_proportion_blockout_leg_foot_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 71b7506..1acbf38 100644 --- a/src/meshops/mcp/tools.py +++ b/src/meshops/mcp/tools.py @@ -1430,3 +1430,30 @@ def mesh_proportion_blockout_hip_glute_compare( scene_dump=_resolve_tool_path(scene_dump, work_root) if scene_dump else None, force=force, ) + + +def mesh_proportion_blockout_leg_foot_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 — LEG_FOOT_COMPARE_HONESTY.""" + from meshops.proportion.leg_foot_compare import run_blockout_leg_foot_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_leg_foot_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 6fadce9..99ab35b 100644 --- a/src/meshops/proportion/assist.py +++ b/src/meshops/proportion/assist.py @@ -141,6 +141,21 @@ "groin_fold_r", "thigh_medial_l", "thigh_medial_r", + # 0127 leg/ankle/foot form-read (not Pose 25-32 / boots) + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "malleolus_med_l", + "malleolus_med_r", + "malleolus_lat_l", + "malleolus_lat_r", + "arch_apex_l", + "arch_apex_r", + "achilles_l", + "achilles_r", + "ball_l", + "ball_r", } ) @@ -216,6 +231,34 @@ "glute_top_seam", ) +# Frozen v1 leg/ankle/foot form-read ids (0127). Existing knee/ankle/calf/heel/toe/foot stay. +LEG_FOOT_FRONT_LANDMARK_IDS: tuple[str, ...] = ( + "malleolus_med_l", + "malleolus_med_r", + "malleolus_lat_l", + "malleolus_lat_r", + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "ball_l", + "ball_r", +) +LEG_FOOT_LEFT_LANDMARK_IDS: tuple[str, ...] = ( + "gastroc_med_l", + "arch_apex_l", + "achilles_l", + "malleolus_med_l", +) +LEG_FOOT_BACK_LANDMARK_IDS: tuple[str, ...] = ( + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "achilles_l", + "achilles_r", +) + 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 7b0a0dc..fbdf66c 100644 --- a/src/meshops/proportion/blockout_recipe.py +++ b/src/meshops/proportion/blockout_recipe.py @@ -3216,6 +3216,37 @@ def _sync_calf_distal_to_ankle( messages.append(f"calf_{side}: distal/cyl p1 Y synced to ank_foot ({ay:.4f})") +def _apply_measured_calf_cyl_y( + parts: list[RecipePart], + report: ProportionReport, + messages: list[str], +) -> None: + """0127: overlay measured gastroc_med_* Y onto calf_cyl p0 after B6, then mid.""" + by_name = {p.name: p for p in parts} + for side in ("l", "r"): + my = _measured_lm_m(report, f"gastroc_med_{side}", y=True) + if my is None: + continue + cyl = by_name.get(f"RECIPE_calf_cyl_{side}") + if cyl is None or cyl.p0 is None or len(cyl.p0) < 3: + continue + cyl.p0 = [float(cyl.p0[0]), my, float(cyl.p0[2])] + cyl.placement = "full3d" + taper = by_name.get(f"RECIPE_calf_taper_dist_{side}") + dest: list[float] | None = None + if taper is not None and taper.p1 is not None and len(taper.p1) >= 3: + dest = [float(taper.p1[0]), float(taper.p1[1]), float(taper.p1[2])] + elif cyl.p1 is not None and len(cyl.p1) >= 3: + dest = [float(cyl.p1[0]), float(cyl.p1[1]), float(cyl.p1[2])] + if dest is not None: + mid = _calf_split_mid(list(cyl.p0), dest) + cyl.p1 = list(mid) + if taper is not None: + taper.p0 = list(mid) + taper.placement = "full3d" + messages.append(f"leg_foot: measured calf_cyl y={my:.4f} ({side})") + + def _noskel_arm_endpoint_ys( band_id: str, y0: float | None, @@ -4671,6 +4702,8 @@ def build_blockout_recipe( # 0034 B6 / 0096: distal/taper p1 Y ← ank_foot after feet (legacy: cyl.p1) _sync_calf_distal_to_ankle(parts, messages) + # 0127: measured gastroc Y after B6 (B33 — then recompute mid; B32 — do not abs). + _apply_measured_calf_cyl_y(parts, report, messages) # 0027 profile emit after base (skip_roles already applied) if profile is not None: @@ -7278,6 +7311,7 @@ def run_blockout_emit_setup( "_apply_glute_seat_mass", "_apply_head_pitch", "_apply_join_ready_overlaps", + "_apply_measured_calf_cyl_y", "_apply_mid_back_plane", "_apply_neck_column_priors", "_apply_neck_diameter_base", diff --git a/src/meshops/proportion/extremity_recipe.py b/src/meshops/proportion/extremity_recipe.py index 9db9a28..f26ec9e 100644 --- a/src/meshops/proportion/extremity_recipe.py +++ b/src/meshops/proportion/extremity_recipe.py @@ -634,6 +634,15 @@ def finger_primary_axis( # --------------------------------------------------------------------------- +def _measured_arch_z_m(report: ProportionReport, side: str) -> float | None: + """Finite arch_apex_{side} z_m, else None (0127 — never invent).""" + lm = report.landmarks_xyz.get(f"arch_apex_{side}") + if lm is None or lm.z_m is None: + return None + val = float(lm.z_m) + return val if math.isfinite(val) else None + + def build_foot_parts( report: ProportionReport, *, @@ -973,6 +982,16 @@ def _build_foot_side( parent_joint=pj_ank, ) ) + # 0127: measured arch_apex Z after arch write (keep ARCH_SOFT_RY_FRAC_HALF_DEPTH). + arch_part = out[-1] + measured_z = _measured_arch_z_m(report, side) + if measured_z is not None and arch_part.center is not None and len(arch_part.center) >= 3: + arch_part.center = [ + float(arch_part.center[0]), + float(arch_part.center[1]), + measured_z, + ] + messages.append(f"leg_foot: measured arch z={measured_z:.4f} ({side})") # ank_foot — name MUST contain ank_foot (B2) ank_name = f"RECIPE_ank_foot_{side}" diff --git a/src/meshops/proportion/fuse.py b/src/meshops/proportion/fuse.py index 31bf542..49a8891 100644 --- a/src/meshops/proportion/fuse.py +++ b/src/meshops/proportion/fuse.py @@ -20,6 +20,8 @@ FACE_LEFT_LANDMARK_IDS, HIP_GLUTE_BACK_LANDMARK_IDS, HIP_GLUTE_LEFT_LANDMARK_IDS, + LEG_FOOT_BACK_LANDMARK_IDS, + LEG_FOOT_LEFT_LANDMARK_IDS, TORSO_BACK_LANDMARK_IDS, TORSO_LEFT_LANDMARK_IDS, ) @@ -206,7 +208,8 @@ def fuse_xyz( xyz.z_m = z * height_m out[lid] = xyz - # 0125/0126: back-view X/Z for torso + hip/glute form-read ids (not DEPTH_PAIRS; Y from left). + # 0125/0126/0127: back-view X/Z for torso + hip/glute + leg/foot 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) @@ -217,7 +220,11 @@ 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, *HIP_GLUTE_BACK_LANDMARK_IDS): + for lid in ( + *TORSO_BACK_LANDMARK_IDS, + *HIP_GLUTE_BACK_LANDMARK_IDS, + *LEG_FOOT_BACK_LANDMARK_IDS, + ): src_lm = back.landmarks.get(lid) if src_lm is None: continue @@ -341,11 +348,12 @@ def fuse_xyz( mid.x_m = x_ref * height_m out[mid_id] = mid - # 0124/0125/0126: same-id left overlay for face + torso + hip/glute Y. + # 0124/0125/0126/0127: same-id left overlay for face + torso + hip/glute + leg/foot Y. for lid in ( *FACE_LEFT_LANDMARK_IDS, *TORSO_LEFT_LANDMARK_IDS, *HIP_GLUTE_LEFT_LANDMARK_IDS, + *LEG_FOOT_LEFT_LANDMARK_IDS, ): src_lm = left.landmarks.get(lid) if src_lm is None: diff --git a/src/meshops/proportion/honesty.py b/src/meshops/proportion/honesty.py index e5f78ba..f5d8f5f 100644 --- a/src/meshops/proportion/honesty.py +++ b/src/meshops/proportion/honesty.py @@ -23,6 +23,7 @@ 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" +LEG_FOOT_COMPARE_HONESTY = "proportion_leg_foot_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/leg_foot_compare.py b/src/meshops/proportion/leg_foot_compare.py new file mode 100644 index 0000000..defccf3 --- /dev/null +++ b/src/meshops/proportion/leg_foot_compare.py @@ -0,0 +1,715 @@ +"""Leg/ankle/foot landmark compare: photo vs RECIPE vs optional live scene (track 0127). + +Authoring QA only — LEG_FOOT_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 LEG_FOOT_COMPARE_HONESTY +from meshops.proportion.models import LandmarkXYZ, ProportionReport + +LEG_FOOT_COMPARE_SCHEMA_VERSION: Final[Literal["1.0.0"]] = "1.0.0" +LEG_FOOT_COMPARE_JSON: Final[str] = "leg_foot_compare.json" +LEG_FOOT_METRICS_JSON: Final[str] = "leg_foot_metrics.json" +LEG_FOOT_COMPARE_ROLES: Final[tuple[str, ...]] = ( + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "malleolus_med_l", + "malleolus_med_r", + "malleolus_lat_l", + "malleolus_lat_r", + "arch_apex_l", + "arch_apex_r", + "achilles_l", + "achilles_r", + "ball_l", + "ball_r", + "knee_l", + "knee_r", + "ankle_l", + "ankle_r", + "calf_front", + "calf_back", + "heel_l", + "heel_r", + "toe_l", + "toe_r", + "foot_front", + "foot_back", +) +SUGGESTED_ACTIONS: Final[frozenset[str]] = frozenset( + {"skip", "hold_priors", "soft_adjust", "session_leg_foot", "remake_0111"} +) +FORM_READ_TOKENS: Final[frozenset[str]] = frozenset( + { + "ank_float_above_heel", + "plate_past_toe_tips", + "calf_pedestal", + "arch_flat", + "ball_narrow_of_toes", + "missing_id", + } +) +ANK_FLOAT_ABOVE_HEEL_M: Final[float] = 0.015 +PLATE_PAST_TOE_TIPS_M: Final[float] = 0.015 +CALF_PEDESTAL_M: Final[float] = 0.010 +ARCH_FLAT_M: Final[float] = 0.004 +BALL_NARROW_FRAC: Final[float] = 0.80 + +_ROLE_PART: dict[str, str] = { + "gastroc_med_l": "RECIPE_calf_cyl_l", + "gastroc_med_r": "RECIPE_calf_cyl_r", + "gastroc_lat_l": "RECIPE_calf_cyl_l", + "gastroc_lat_r": "RECIPE_calf_cyl_r", + "malleolus_med_l": "RECIPE_ank_foot_l", + "malleolus_med_r": "RECIPE_ank_foot_r", + "malleolus_lat_l": "RECIPE_ank_foot_l", + "malleolus_lat_r": "RECIPE_ank_foot_r", + "arch_apex_l": "RECIPE_arch_soft_l", + "arch_apex_r": "RECIPE_arch_soft_r", + "achilles_l": "RECIPE_heel_l", + "achilles_r": "RECIPE_heel_r", + "ball_l": "RECIPE_ball_soft_l", + "ball_r": "RECIPE_ball_soft_r", + "knee_l": "RECIPE_knee_soft_l", + "knee_r": "RECIPE_knee_soft_r", + "ankle_l": "RECIPE_ank_foot_l", + "ankle_r": "RECIPE_ank_foot_r", + "calf_front": "RECIPE_calf_cyl_l", + "calf_back": "RECIPE_calf_cyl_l", + "heel_l": "RECIPE_heel_l", + "heel_r": "RECIPE_heel_r", + "toe_l": "RECIPE_toe_1_l", + "toe_r": "RECIPE_toe_1_r", + "foot_front": "RECIPE_foot_plate_l", + "foot_back": "RECIPE_foot_plate_l", +} + +# B4: soft_adjust only for new-id gastroc Y + arch Z. +# malleolus/ball/achilles/HAVE ids / DEPTH_PAIRS stay hold_priors. +_SOFT_ADJUST_IDS: Final[frozenset[str]] = frozenset( + { + "gastroc_med_l", + "gastroc_med_r", + "arch_apex_l", + "arch_apex_r", + } +) +_CAPSULE_ROLES: Final[frozenset[str]] = frozenset( + { + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "calf_front", + "calf_back", + "toe_l", + "toe_r", + } +) + +_KNOB: dict[str, str] = { + "gastroc_med_l": "CALF_BELLY_REAR_FRAC", + "gastroc_med_r": "CALF_BELLY_REAR_FRAC", + "gastroc_lat_l": "CALF_BELLY_LAT_FRAC", + "gastroc_lat_r": "CALF_BELLY_LAT_FRAC", + "malleolus_med_l": "ANK_RY_FRAC_HALF_W", + "malleolus_med_r": "ANK_RY_FRAC_HALF_W", + "malleolus_lat_l": "ANK_RY_FRAC_HALF_W", + "malleolus_lat_r": "ANK_RY_FRAC_HALF_W", + "arch_apex_l": "ARCH_SOFT_RY_FRAC_HALF_DEPTH", + "arch_apex_r": "ARCH_SOFT_RY_FRAC_HALF_DEPTH", + "achilles_l": "ANK_RZ_FRAC_HALF_W", + "achilles_r": "ANK_RZ_FRAC_HALF_W", + "ball_l": "BALL_SOFT_RY_FRAC_HALF_DEPTH", + "ball_r": "BALL_SOFT_RY_FRAC_HALF_DEPTH", + "knee_l": "KNEE_SOFT_FRAC", + "knee_r": "KNEE_SOFT_FRAC", + "ankle_l": "ANK_RY_FRAC_HALF_W", + "ankle_r": "ANK_RY_FRAC_HALF_W", + "calf_front": "CALF_BELLY_REAR_FRAC", + "calf_back": "CALF_BELLY_REAR_FRAC", + "heel_l": "ANK_RY_FRAC_HALF_W", + "heel_r": "ANK_RY_FRAC_HALF_W", + "toe_l": "TOE_TIP_PAD_RY_FRAC", + "toe_r": "TOE_TIP_PAD_RY_FRAC", + "foot_front": "FOOT_LEN_VISUAL_MIN_FRAC_H", + "foot_back": "FOOT_LEN_VISUAL_MIN_FRAC_H", +} + +_KNOWN_DUMP_NAMES: Final[frozenset[str]] = frozenset( + { + "RECIPE_calf_a_l", + "RECIPE_calf_a_r", + "RECIPE_calf_cyl_l", + "RECIPE_calf_cyl_r", + "RECIPE_calf_taper_dist_l", + "RECIPE_calf_taper_dist_r", + "RECIPE_calf_b_l", + "RECIPE_calf_b_r", + "RECIPE_ank_foot_l", + "RECIPE_ank_foot_r", + "RECIPE_heel_l", + "RECIPE_heel_r", + "RECIPE_foot_plate_l", + "RECIPE_foot_plate_r", + "RECIPE_arch_soft_l", + "RECIPE_arch_soft_r", + "RECIPE_ball_soft_l", + "RECIPE_ball_soft_r", + "RECIPE_toe_1_l", + "RECIPE_toe_1_r", + "RECIPE_toe_tip_1_l", + "RECIPE_toe_tip_1_r", + "RECIPE_knee_soft_l", + "RECIPE_knee_soft_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_leg_foot_part(parts: list[Any], role_id: str) -> dict[str, Any] | None: + """Extract one leg/foot role from RECIPE parts (B26/B39 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 _CAPSULE_ROLES or kind == "capsule" + 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 == "foot_front" and center is not None and ry is not None: + center = [center[0], center[1] - ry, center[2]] + if role_id == "foot_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 LegFootMetrics(BaseModel): + """Sidecar leg/foot 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 = LEG_FOOT_COMPARE_HONESTY + ank_bottom_vs_heel_bottom_m: float | None = None + plate_front_vs_toe_tip_m: float | None = None + calf_belly_r_m: float | None = None + calf_taper_r_m: float | None = None + ank_ry_over_rx: float | None = None + arch_rise_m: float | None = None + ball_span_m: float | None = None + y_m: dict[str, float | None] = Field(default_factory=dict) + + +class LegFootCompareCoord(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 LegFootCompareDelta(BaseModel): + model_config = ConfigDict(extra="forbid") + + x: float | None = None + y: float | None = None + z: float | None = None + + +class LegFootCompareRole(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + measured: LegFootCompareCoord | None = None + recipe: dict[str, Any] | None = None + live: dict[str, Any] | None = None + delta_mm: LegFootCompareDelta | 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_leg_foot", "remake_0111"] = ( + "skip" + ) + + +class LegFootComparePackage(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: Literal["1.0.0"] = LEG_FOOT_COMPARE_SCHEMA_VERSION + honesty: str = LEG_FOOT_COMPARE_HONESTY + region: Literal["leg_foot"] = "leg_foot" + ok: bool = False + roles: list[LegFootCompareRole] = Field(default_factory=list) + messages: list[str] = Field(default_factory=list) + leg_foot_metrics: LegFootMetrics | 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 _bottom_z(part: dict[str, Any] | None) -> float | None: + if part is None: + return None + center = _as_vec3(part.get("center")) + rz = _as_float(part.get("rz_m")) + if center is None or rz is None: + return None + return center[2] - rz + + +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 _p0_z(part: dict[str, Any] | None) -> float | None: + if part is None: + return None + p0 = _as_vec3(part.get("p0")) + if p0 is None: + return None + return p0[2] + + +def _toe_splay_m(parts: list[Any]) -> float | None: + left = _find_part(parts, "RECIPE_toe_1_l") + right = _find_part(parts, "RECIPE_toe_1_r") + if left is None or right is None: + return None + lp = _as_vec3(left.get("p0")) or _as_vec3(left.get("p1")) + rp = _as_vec3(right.get("p0")) or _as_vec3(right.get("p1")) + if lp is None or rp is None: + return None + return abs(rp[0] - lp[0]) + + +def build_leg_foot_metrics( + report: ProportionReport, + recipe: Any | None = None, +) -> LegFootMetrics: + """Compute leg_foot_metrics from landmarks_xyz (+ optional RECIPE parts).""" + parts = _recipe_parts(recipe) + ank = _find_part(parts, "RECIPE_ank_foot_l") or _find_part(parts, "RECIPE_ank_foot_r") + heel = _find_part(parts, "RECIPE_heel_l") or _find_part(parts, "RECIPE_heel_r") + plate = _find_part(parts, "RECIPE_foot_plate_l") or _find_part(parts, "RECIPE_foot_plate_r") + tip = _find_part(parts, "RECIPE_toe_tip_1_l") or _find_part(parts, "RECIPE_toe_tip_1_r") + cyl = _find_part(parts, "RECIPE_calf_cyl_l") or _find_part(parts, "RECIPE_calf_cyl_r") + taper = _find_part(parts, "RECIPE_calf_taper_dist_l") or _find_part( + parts, "RECIPE_calf_taper_dist_r" + ) + arch = _find_part(parts, "RECIPE_arch_soft_l") or _find_part(parts, "RECIPE_arch_soft_r") + + ank_bottom = _bottom_z(ank) + heel_bottom = _bottom_z(heel) + ank_vs_heel: float | None = None + if ank_bottom is not None and heel_bottom is not None: + ank_vs_heel = ank_bottom - heel_bottom + + plate_front = _surface_y(plate, rear=False) + tip_c = _as_vec3(tip.get("center")) if tip is not None else None + tip_y = tip_c[1] if tip_c is not None else None + plate_vs_tip: float | None = None + if plate_front is not None and tip_y is not None: + plate_vs_tip = plate_front - tip_y + + arch_c = _as_vec3(arch.get("center")) if arch is not None else None + plate_c = _as_vec3(plate.get("center")) if plate is not None else None + arch_rise: float | None = None + if arch_c is not None and plate_c is not None: + arch_rise = arch_c[2] - plate_c[2] + + lms = report.landmarks_xyz + y_fields: dict[str, float | None] = {} + for lid in LEG_FOOT_COMPARE_ROLES: + lm = lms.get(lid) + y_fields[lid] = _as_float(lm.y_m) if lm is not None else None + + ball_l = lms.get("ball_l") + ball_r = lms.get("ball_r") + xl = _as_float(ball_l.x_m) if ball_l is not None else None + xr = _as_float(ball_r.x_m) if ball_r is not None else None + ball_span: float | None = None + if xl is not None and xr is not None: + ball_span = abs(xr - xl) + + return LegFootMetrics( + ank_bottom_vs_heel_bottom_m=ank_vs_heel, + plate_front_vs_toe_tip_m=plate_vs_tip, + calf_belly_r_m=_as_float(cyl.get("radius_m")) if cyl is not None else None, + calf_taper_r_m=_as_float(taper.get("radius_m")) if taper is not None else None, + ank_ry_over_rx=_ry_over_rx(ank), + arch_rise_m=arch_rise, + ball_span_m=ball_span, + y_m=y_fields, + ) + + +def _measured_coord(lm: LandmarkXYZ | None) -> LegFootCompareCoord | 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 LegFootCompareCoord( + x_m=x_m, + y_m=y_m, + z_m=z_m, + confidence=float(lm.confidence), + sources=list(lm.sources), + ) + + +def _anchor_xyz(recipe: dict[str, Any] | None) -> list[float] | None: + if recipe is None: + return None + kind = str(recipe.get("kind") or "") + if kind == "capsule": + p0 = _as_vec3(recipe.get("p0")) + if p0 is not None: + return p0 + center = _as_vec3(recipe.get("center")) + if center is not None: + return center + return _as_vec3(recipe.get("p0")) + + +def _delta_mm( + measured: LegFootCompareCoord | None, + recipe: dict[str, Any] | None, +) -> LegFootCompareDelta | None: + if measured is None or recipe is None: + return None + # B39: capsule roles use p0 (live calf_cyl center=null). + anchor = _anchor_xyz(recipe) + if anchor is None: + return None + mx = _as_float(measured.x_m) + my = _as_float(measured.y_m) + mz = _as_float(measured.z_m) + dx = (mx - anchor[0]) * 1000.0 if mx is not None else None + dy = (my - anchor[1]) * 1000.0 if my is not None else None + dz = (mz - anchor[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 LegFootCompareDelta(x=dx, y=dy, z=dz) + + +def _suggest( + measured: LegFootCompareCoord | None, + recipe: dict[str, Any] | None, + delta: LegFootCompareDelta | None, + role_id: str, +) -> Literal["skip", "hold_priors", "soft_adjust", "session_leg_foot", "remake_0111"]: + if measured is None: + return "skip" + if recipe is None or _anchor_xyz(recipe) is None: + return "skip" + if role_id not in _SOFT_ADJUST_IDS: + return "hold_priors" + if delta is None: + return "hold_priors" + # B36: Y/Z-only consume — ignore delta.x. + vals = [abs(v) for v in (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="leg_foot_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="leg_foot_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="leg_foot_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="leg_foot_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="leg_foot_compare_failed", + details={"path": str(path), "index": i, "name": name}, + ) + out.append(part) + return out + + +def _has_finite_leg_foot(lms: dict[str, LandmarkXYZ]) -> bool: + for lid in LEG_FOOT_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_leg_foot_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 / LEG_FOOT_COMPARE_JSON + if dest.exists() and not force: + raise ProportionError( + f"leg/foot compare exists: {dest} (pass --force)", + code="leg_foot_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] = [ + "LEG_FOOT_COMPARE_HONESTY — authoring QA only; not mesh or print success", + ] + metrics = build_leg_foot_metrics(rep, recipe=pkg) + ank_float = ( + metrics.ank_bottom_vs_heel_bottom_m is not None + and metrics.ank_bottom_vs_heel_bottom_m >= ANK_FLOAT_ABOVE_HEEL_M + ) + if ank_float: + messages.append(f"ank_float_above_heel={metrics.ank_bottom_vs_heel_bottom_m:.4f}") + plate_past = ( + metrics.plate_front_vs_toe_tip_m is not None + and metrics.plate_front_vs_toe_tip_m <= -PLATE_PAST_TOE_TIPS_M + ) + if plate_past: + messages.append(f"plate_past_toe_tips_m={metrics.plate_front_vs_toe_tip_m:.4f}") + + calf_a = _find_part(parts, "RECIPE_calf_a_l") or _find_part(parts, "RECIPE_calf_a_r") + cyl = _find_part(parts, "RECIPE_calf_cyl_l") or _find_part(parts, "RECIPE_calf_cyl_r") + calf_a_c = _as_vec3(calf_a.get("center")) if calf_a is not None else None + cyl_p0z = _p0_z(cyl) + pedestal = False + if calf_a_c is not None and cyl_p0z is not None: + pedestal = (calf_a_c[2] - cyl_p0z) >= CALF_PEDESTAL_M + if pedestal: + messages.append("calf_pedestal") + + arch_flat = metrics.arch_rise_m is not None and metrics.arch_rise_m < ARCH_FLAT_M + if arch_flat: + messages.append(f"arch_flat_m={metrics.arch_rise_m:.4f}") + + splay = _toe_splay_m(parts) + ball_narrow = False + if metrics.ball_span_m is not None and splay is not None and splay > 0: + ball_narrow = metrics.ball_span_m < BALL_NARROW_FRAC * splay + if ball_narrow: + messages.append(f"ball_narrow_of_toes span={metrics.ball_span_m:.4f}") + + lms = rep.landmarks_xyz + roles: list[LegFootCompareRole] = [] + for lid in LEG_FOOT_COMPARE_ROLES: + measured = _measured_coord(lms.get(lid)) + rec = extract_recipe_leg_foot_part(parts, lid) + live = extract_recipe_leg_foot_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 ank_float and lid.startswith(("ankle_", "heel_", "malleolus_")): + form.append("ank_float_above_heel") + if plate_past and lid in ("foot_front", "toe_l", "toe_r"): + form.append("plate_past_toe_tips") + if pedestal and lid.startswith("gastroc_"): + form.append("calf_pedestal") + if arch_flat and lid.startswith("arch_apex_"): + form.append("arch_flat") + if ball_narrow and lid.startswith("ball_"): + form.append("ball_narrow_of_toes") + 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( + LegFootCompareRole( + 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 = LegFootComparePackage( + ok=True, + roles=roles, + messages=messages, + leg_foot_metrics=metrics, + package_path=str(dest), + ) + dest.write_text(package.model_dump_json(indent=2) + "\n", encoding="utf-8") + sidecar = out_dir / LEG_FOOT_METRICS_JSON + if _has_finite_leg_foot(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/template.py b/src/meshops/proportion/template.py index 1daef14..44ed861 100644 --- a/src/meshops/proportion/template.py +++ b/src/meshops/proportion/template.py @@ -85,6 +85,17 @@ "groin_fold_r", "thigh_medial_l", "thigh_medial_r", + # 0127 leg/ankle/foot form-read (front) + "malleolus_med_l", + "malleolus_med_r", + "malleolus_lat_l", + "malleolus_lat_r", + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "ball_l", + "ball_r", ) # Left profile: depth pairs + optional heel/breast hang. No toe_l/r (B1 — front only). @@ -132,6 +143,11 @@ "glute_bottom_l", "glute_top_seam", "psis_l", + # 0127 leg/ankle/foot Y / pride (never invent from front-only) + "gastroc_med_l", + "arch_apex_l", + "achilles_l", + "malleolus_med_l", ) # Top-level edge_pairs stubs (sibling of views) — fill [[x0,y0],[x1,y1]]. @@ -189,6 +205,13 @@ "glute_bottom_l", "glute_bottom_r", "glute_top_seam", + # 0127 leg/ankle/foot form-read (back — Package A currently empty here) + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "achilles_l", + "achilles_r", ) # 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 000ffca..b8c1de4 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) == 50 + assert len(TOOL_NAMES) == 51 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 2a024a7..83fa62f 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) == 50 + assert len(TOOL_NAMES) == 51 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 871044a..9c6156d 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) == 50 + assert len(TOOL_NAMES) == 51 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 5ec1fc4..ba436a1 100644 --- a/tests/test_hip_soft_docs.py +++ b/tests/test_hip_soft_docs.py @@ -80,7 +80,7 @@ def test_t5_hip_rx_scale_hold() -> None: def test_t6_mcp_catalog_47() -> None: """T6: MCP catalog stays 47.""" - assert len(TOOL_NAMES) == 50 + assert len(TOOL_NAMES) == 51 def test_t7_no_hip_cli_command() -> None: diff --git a/tests/test_limb_soft_scale_docs.py b/tests/test_limb_soft_scale_docs.py index 66a74c0..d8a5460 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) == 50 + assert len(TOOL_NAMES) == 51 def test_t7_no_limb_scale_cli_command() -> None: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index c88f60a..663ff00 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -77,13 +77,14 @@ async def _body() -> None: assert "mesh_proportion_blockout_face_compare" in names assert "mesh_proportion_blockout_torso_compare" in names assert "mesh_proportion_blockout_hip_glute_compare" in names - assert len(names) == 50 + assert "mesh_proportion_blockout_leg_foot_compare" in names + assert len(names) == 51 _run(_body()) def test_mcp__proportion_tools_in_catalog() -> None: - """Explicit 0110 catalog freeze: proportion tools + open-setup; len == 50.""" + """Explicit catalog freeze: proportion tools + open-setup; len == 51.""" async def _body() -> None: server = build_server() @@ -115,24 +116,25 @@ async def _body() -> None: "mesh_proportion_blockout_face_compare", "mesh_proportion_blockout_torso_compare", "mesh_proportion_blockout_hip_glute_compare", + "mesh_proportion_blockout_leg_foot_compare", ): assert n in names - assert len(names) == 50 + assert len(names) == 51 assert names >= TOOL_NAMES - assert len(TOOL_NAMES) == 50 + assert len(TOOL_NAMES) == 51 _run(_body()) -def test_mcp__t10_t11_join_ready_and_catalog_47() -> None: - """T10/T11: catalog 50; emit-setup/fuse-plan/open-setup; recipe join_ready; feedback tool.""" +def test_mcp__t10_t11_join_ready_and_catalog_51() -> None: + """T10/T11: catalog 51; 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) == 50 + assert len(by_name) == 51 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 @@ -140,6 +142,7 @@ async def _body() -> None: 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 + assert "mesh_proportion_blockout_leg_foot_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 422d949..98f6978 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) == 50 + assert len(TOOL_NAMES) == 51 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 4aade13..e0ae159 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) == 50 + assert len(TOOL_NAMES) == 51 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 69b8632..6f8bb24 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) == 50 + assert len(TOOL_NAMES) == 51 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) == 50 + assert len(names) == 51 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 eba76bc..a7c8683 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) == 50 + assert len(TOOL_NAMES) == 51 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 5e2bf3b..9292d05 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) == 50 + assert len(TOOL_NAMES) == 51 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 c5283ff..5e467a2 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) == 50 + assert len(TOOL_NAMES) == 51 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 b491315..1cee5de 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) == 50 + assert len(TOOL_NAMES) == 51 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 3635e1b..4e6ede8 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) == 50 + assert len(TOOL_NAMES) == 51 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 cfcc9db..05c4083 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) == 50 + assert len(TOOL_NAMES) == 51 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 1510b6f..8a7968d 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) == 50 + assert len(TOOL_NAMES) == 51 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 bfcb5a6..7b93d07 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) == 50 + assert len(TOOL_NAMES) == 51 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 d4fd3b4..7bf6125 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) == 50 + assert len(TOOL_NAMES) == 51 assert RECIPE_SCHEMA_VERSION == "1.4.0" diff --git a/tests/test_proportion_extremity_recipe.py b/tests/test_proportion_extremity_recipe.py index e6e81b3..a03372d 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) == 50 + assert len(TOOL_NAMES) == 51 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) == 50 + assert len(names) == 51 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) == 50 + assert len(TOOL_NAMES) == 51 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 b553e05..dbe5768 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) == 50 + assert len(TOOL_NAMES) == 51 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 83b8477..0aa2390 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) == 50 + assert len(TOOL_NAMES) == 51 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 c894662..f21bd15 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) == 50 + assert len(TOOL_NAMES) == 51 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) == 50 + assert len(names) == 51 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 9701629..4f97c05 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) == 50 + assert len(TOOL_NAMES) == 51 diff --git a/tests/test_proportion_foot_scale_plus.py b/tests/test_proportion_foot_scale_plus.py index a905bfe..708f19c 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) == 50 + assert len(TOOL_NAMES) == 51 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 6e00a74..08e79a3 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) == 50 + assert len(TOOL_NAMES) == 51 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 b136d27..a4132d3 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) == 50 + assert len(TOOL_NAMES) == 51 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 937c59d..15d5e05 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) == 50 + assert len(TOOL_NAMES) == 51 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 f065327..85e043d 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) == 50 + assert len(TOOL_NAMES) == 51 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 4aa6915..caf78da 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) == 50 + assert len(TOOL_NAMES) == 51 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 204ee6d..24677e4 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) == 50 + assert len(TOOL_NAMES) == 51 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 2e79017..76150aa 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) == 50 + assert len(TOOL_NAMES) == 51 diff --git a/tests/test_proportion_hip_glute_compare.py b/tests/test_proportion_hip_glute_compare.py index 6ea8827..f20bb08 100644 --- a/tests/test_proportion_hip_glute_compare.py +++ b/tests/test_proportion_hip_glute_compare.py @@ -572,7 +572,7 @@ def test_d9_glute_outer_hold_priors_alias() -> None: 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 + assert len(TOOL_NAMES) == 51 def test_f2_cli_contains_verb() -> None: diff --git a/tests/test_proportion_hip_glute_soft_consume.py b/tests/test_proportion_hip_glute_soft_consume.py index 74f3bed..9a0bd87 100644 --- a/tests/test_proportion_hip_glute_soft_consume.py +++ b/tests/test_proportion_hip_glute_soft_consume.py @@ -194,4 +194,4 @@ def test_e_top_seam_y_both_sides() -> None: def test_e_mcp_catalog_50() -> None: - assert len(TOOL_NAMES) == 50 + assert len(TOOL_NAMES) == 51 diff --git a/tests/test_proportion_hip_hierarchy.py b/tests/test_proportion_hip_hierarchy.py index febedd8..f823cb7 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) == 50 + assert len(TOOL_NAMES) == 51 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 e1d3eb5..ec59966 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) == 50 + assert len(TOOL_NAMES) == 51 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 b447472..ddbf036 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) == 50 + assert len(TOOL_NAMES) == 51 def test_t8_product_path_constraints() -> None: diff --git a/tests/test_proportion_leg_foot_compare.py b/tests/test_proportion_leg_foot_compare.py new file mode 100644 index 0000000..9007f89 --- /dev/null +++ b/tests/test_proportion_leg_foot_compare.py @@ -0,0 +1,784 @@ +"""Track 0127 — blockout-leg-foot-compare JSON / honesty / scene-dump (offline). + +Authoring QA only — LEG_FOOT_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.honesty import LEG_FOOT_COMPARE_HONESTY, PROPORTION_HONESTY +from meshops.proportion.leg_foot_compare import ( + LEG_FOOT_COMPARE_ROLES, + LEG_FOOT_COMPARE_SCHEMA_VERSION, + SUGGESTED_ACTIONS, + build_leg_foot_metrics, + extract_recipe_leg_foot_part, + run_blockout_leg_foot_compare, +) +from meshops.proportion.models import ( + PROPORTION_SCHEMA_VERSION, + LandmarkXYZ, + ProportionReport, + QualityFlags, +) + +runner = CliRunner() + +_SUGGESTED = frozenset({"skip", "hold_priors", "soft_adjust", "session_leg_foot", "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( + *, + ank_float: bool = True, + plate_past: bool = True, + pedestal: bool = False, +) -> dict[str, Any]: + ank_z = 0.1314 if ank_float else 0.0552 + plate_cy = -0.0172 if plate_past else -0.1105 + calf_a_z = 0.5817 if pedestal else 0.5717 + return _recipe_doc( + parts=[ + _ellipsoid( + "RECIPE_calf_a_l", + "limb_segment", + [-0.0821, 0.0, calf_a_z], + 0.03853, + 0.03853, + 0.03853, + ), + _ellipsoid( + "RECIPE_calf_a_r", + "limb_segment", + [0.0821, 0.0, calf_a_z], + 0.03853, + 0.03853, + 0.03853, + ), + _capsule( + "RECIPE_calf_cyl_l", + "limb_segment", + [-0.0976, 0.0217, 0.5717], + [-0.0949, 0.0424, 0.3867], + 0.05167, + ), + _capsule( + "RECIPE_calf_cyl_r", + "limb_segment", + [0.0976, 0.0217, 0.5717], + [0.0949, 0.0424, 0.3867], + 0.05167, + ), + _capsule( + "RECIPE_calf_taper_dist_l", + "limb_segment", + [-0.0949, 0.0424, 0.3867], + [-0.0911, 0.0711, 0.1314], + 0.03503, + ), + _capsule( + "RECIPE_calf_taper_dist_r", + "limb_segment", + [0.0949, 0.0424, 0.3867], + [0.0911, 0.0711, 0.1314], + 0.03503, + ), + _ellipsoid( + "RECIPE_calf_b_l", + "limb_segment", + [-0.0911, 0.0711, 0.1314], + 0.03153, + 0.03153, + 0.03153, + ), + _ellipsoid( + "RECIPE_calf_b_r", + "limb_segment", + [0.0911, 0.0711, 0.1314], + 0.03153, + 0.03153, + 0.03153, + ), + _ellipsoid( + "RECIPE_ank_foot_l", + "ankle_bridge", + [-0.0911, 0.0711, ank_z], + 0.04237, + 0.03305, + 0.07627, + ), + _ellipsoid( + "RECIPE_ank_foot_r", + "ankle_bridge", + [0.0911, 0.0711, ank_z], + 0.04237, + 0.03305, + 0.07627, + ), + _ellipsoid( + "RECIPE_heel_l", + "heel", + [-0.0911, 0.0875, 0.0552], + 0.0445, + 0.0397, + 0.0381, + ), + _ellipsoid( + "RECIPE_heel_r", + "heel", + [0.0911, 0.0875, 0.0552], + 0.0445, + 0.0397, + 0.0381, + ), + _ellipsoid( + "RECIPE_foot_plate_l", + "foot_plate", + [-0.0911, plate_cy, 0.0301], + 0.0445, + 0.1324, + 0.0301, + ), + _ellipsoid( + "RECIPE_foot_plate_r", + "foot_plate", + [0.0911, plate_cy, 0.0301], + 0.0445, + 0.1324, + 0.0301, + ), + _ellipsoid( + "RECIPE_arch_soft_l", + "ball_soft", + [-0.0911, -0.0066, 0.0376], + 0.0390, + 0.0238, + 0.0346, + ), + _ellipsoid( + "RECIPE_arch_soft_r", + "ball_soft", + [0.0911, -0.0066, 0.0376], + 0.0390, + 0.0238, + 0.0346, + ), + _ellipsoid( + "RECIPE_ball_soft_l", + "ball_soft", + [-0.0911, -0.0613, 0.0361], + 0.0403, + 0.0212, + 0.0331, + ), + _ellipsoid( + "RECIPE_ball_soft_r", + "ball_soft", + [0.0911, -0.0613, 0.0361], + 0.0403, + 0.0212, + 0.0331, + ), + _capsule( + "RECIPE_toe_1_l", + "toe_soft", + [-0.0911, -0.0900, 0.0300], + [-0.0911, -0.1105, 0.0300], + 0.0183, + ), + _capsule( + "RECIPE_toe_1_r", + "toe_soft", + [0.0911, -0.0900, 0.0300], + [0.0911, -0.1105, 0.0300], + 0.0183, + ), + _ellipsoid( + "RECIPE_toe_tip_1_l", + "toe_soft", + [-0.0911, -0.1105, 0.0300], + 0.0143, + 0.00785, + 0.0143, + ), + _ellipsoid( + "RECIPE_toe_tip_1_r", + "toe_soft", + [0.0911, -0.1105, 0.0300], + 0.0143, + 0.00785, + 0.0143, + ), + _ellipsoid( + "RECIPE_knee_soft_l", + "limb_segment", + [-0.0821, 0.0, 0.5717], + 0.04767, + 0.03909, + 0.05482, + ), + _ellipsoid( + "RECIPE_knee_soft_r", + "limb_segment", + [0.0821, 0.0, 0.5717], + 0.04767, + 0.03909, + 0.05482, + ), + ], + ) + + +def test_b1_ank_vs_heel_when_recipe() -> None: + """B1: recipe present → ank_bottom_vs_heel_bottom_m finite (~0.038 class).""" + metrics = build_leg_foot_metrics(_report(), recipe=_productish_recipe()) + assert metrics is not None + assert metrics.ank_bottom_vs_heel_bottom_m == pytest.approx(0.03803, abs=1e-4) + + +def test_b2_front_only_y_null() -> None: + """B2: front-only measured → y_m null on new leg/foot ids (no invent).""" + report = _report( + { + "gastroc_med_l": _lm("gastroc_med_l", x_m=-0.10, z_m=0.50), + "arch_apex_l": _lm("arch_apex_l", x_m=-0.09, z_m=0.04), + } + ) + metrics = build_leg_foot_metrics(report, recipe=None) + assert metrics is not None + assert metrics.y_m.get("gastroc_med_l") is None + assert metrics.y_m.get("arch_apex_l") is None + + +def test_b3_missing_one_gastroc_no_invent() -> None: + """B3: missing one gastroc_med → no invent contralateral.""" + report = _report({"gastroc_med_l": _lm("gastroc_med_l", x_m=-0.10, y_m=0.08, z_m=0.50)}) + metrics = build_leg_foot_metrics(report, recipe=None) + assert metrics is not None + assert metrics.y_m.get("gastroc_med_l") == pytest.approx(0.08, abs=1e-9) + assert metrics.y_m.get("gastroc_med_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 "leg_foot_metrics" not in dumped + + +def test_b5_ball_span_none_unless_both_finite() -> None: + """B5 / B35: one of ball_l/r missing → ball_span_m is None (never invent from toe).""" + report = _report( + { + "ball_l": _lm("ball_l", x_m=-0.04, z_m=0.03), + "toe_l": _lm("toe_l", x_m=-0.05, z_m=0.03), + "toe_r": _lm("toe_r", x_m=0.05, z_m=0.03), + } + ) + metrics = build_leg_foot_metrics(report, recipe=_productish_recipe()) + assert metrics is not None + assert metrics.ball_span_m is None + both = _report( + { + "ball_l": _lm("ball_l", x_m=-0.04, z_m=0.03), + "ball_r": _lm("ball_r", x_m=0.05, z_m=0.03), + } + ) + both_metrics = build_leg_foot_metrics(both, recipe=None) + assert both_metrics is not None + assert both_metrics.ball_span_m == pytest.approx(0.09, abs=1e-9) + + +def test_c1_recipe_calf_and_ank_extract() -> None: + """C1: recipe JSON extracts RECIPE_calf_cyl_l p0/radius + RECIPE_ank_foot_l axes.""" + cyl = extract_recipe_leg_foot_part(_productish_recipe()["parts"], "gastroc_med_l") + assert cyl is not None + assert cyl["name"] == "RECIPE_calf_cyl_l" + assert cyl["kind"] == "capsule" + assert cyl["p0"] is not None + assert cyl["p0"][1] == pytest.approx(0.0217, abs=1e-6) + assert cyl["radius_m"] == pytest.approx(0.05167, abs=1e-6) + ank = extract_recipe_leg_foot_part(_productish_recipe()["parts"], "ankle_l") + assert ank is not None + assert ank["name"] == "RECIPE_ank_foot_l" + assert ank["center"] is not None + assert ank["rx_m"] == pytest.approx(0.04237, abs=1e-6) + assert ank["ry_m"] == pytest.approx(0.03305, abs=1e-6) + assert ank["rz_m"] == pytest.approx(0.07627, abs=1e-6) + + +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_leg_foot_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=[ + _capsule( + "RECIPE_calf_cyl_l", + "limb_segment", + [-0.10, 0.05, 0.57], + [-0.09, 0.04, 0.38], + 0.05, + ) + ] + ), + ) + payload = run_blockout_leg_foot_compare( + report, + recipe, + tmp_path / "cmp", + scene_dump=dump, + force=True, + ) + gastroc = next(r for r in payload["roles"] if r["id"] == "gastroc_med_l") + assert gastroc["live"] is not None + assert gastroc["live"]["p0"] is not None + assert gastroc["live"]["p0"][1] == pytest.approx(0.05, 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_leg_foot_compare( + report, + recipe, + tmp_path / "cmp", + scene_dump=dump, + force=True, + ) + assert not (tmp_path / "cmp" / "leg_foot_compare.json").is_file() + + +def test_c3b_named_part_without_geometry_fail_closed(tmp_path: Path) -> None: + """C3: named calf 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_calf_cyl_l"}], + } + ) + + "\n", + encoding="utf-8", + ) + with pytest.raises(ProportionError): + run_blockout_leg_foot_compare( + report, + recipe, + tmp_path / "cmp", + scene_dump=dump, + force=True, + ) + assert not (tmp_path / "cmp" / "leg_foot_compare.json").is_file() + + +def test_c4_calf_cyl_capsule_extract_p0() -> None: + """C4 / B26: capsule calf_cyl extract uses p0; rx_m stays null.""" + p0 = [-0.0976, 0.0217, 0.5717] + p1 = [-0.0949, 0.0424, 0.3867] + snap = extract_recipe_leg_foot_part( + [_capsule("RECIPE_calf_cyl_l", "limb_segment", p0, p1, 0.05167)], + "gastroc_med_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["p0"] == p0 + assert snap["radius_m"] == pytest.approx(0.05167, 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=leg_foot.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + assert payload["honesty"] == LEG_FOOT_COMPARE_HONESTY + assert payload["schema_version"] == LEG_FOOT_COMPARE_SCHEMA_VERSION + assert payload["schema_version"] == "1.0.0" + assert payload["region"] == "leg_foot" + raw = json.loads((tmp_path / "cmp" / "leg_foot_compare.json").read_text(encoding="utf-8")) + assert raw["honesty"] == LEG_FOOT_COMPARE_HONESTY + assert raw["region"] == "leg_foot" + + +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_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + assert SUGGESTED_ACTIONS == _SUGGESTED + ids = {r["id"] for r in payload["roles"]} + for lid in LEG_FOOT_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 (capsule p0).""" + report = _write_report( + tmp_path / "report.json", + _report( + { + "gastroc_med_l": _lm("gastroc_med_l", x_m=-0.10, y_m=0.04, z_m=0.5717), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + gastroc = next(r for r in payload["roles"] if r["id"] == "gastroc_med_l") + assert gastroc["delta_mm"] is not None + assert gastroc["delta_mm"]["y"] == pytest.approx((0.04 - 0.0217) * 1000.0, abs=1e-3) + + +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_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + gastroc = next(r for r in payload["roles"] if r["id"] == "gastroc_med_l") + assert gastroc["measured"] is None + assert gastroc["suggested"] == "skip" + assert "missing_id" in gastroc["form_read"] + dumped = json.dumps(payload) + assert "NaN" not in dumped + assert "Infinity" not in dumped + + +def test_d5_ank_float_above_heel(tmp_path: Path) -> None: + """D5: synthetic ank floor - heel floor >= 0.015 -> ank_float_above_heel.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe(ank_float=True)) + payload = run_blockout_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + tokens: list[str] = [] + for role in payload["roles"]: + tokens.extend(role.get("form_read") or []) + assert "ank_float_above_heel" 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-leg-foot-compare", "--help"]) + assert help_result.exit_code == 0 + assert "blockout-leg-foot-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-leg-foot-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"] == "leg_foot" + + +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-leg-foot-compare", + "--report", + str(report), + "--recipe", + str(recipe), + "--out", + str(tmp_path / "cmp"), + "--force", + ], + ) + assert result.exit_code == 0 + assert LEG_FOOT_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_plate_past_toe_tips(tmp_path: Path) -> None: + """D8: synthetic plate front past toe_tip ≥0.015 → plate_past_toe_tips.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe(plate_past=True)) + payload = run_blockout_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + tokens: list[str] = [] + for role in payload["roles"]: + tokens.extend(role.get("form_read") or []) + assert "plate_past_toe_tips" in tokens + + +def test_d9_malleolus_ball_achilles_hold_priors(tmp_path: Path) -> None: + """D9 / B23: malleolus/ball/achilles stay hold_priors even when measured X finite.""" + report = _write_report( + tmp_path / "report.json", + _report( + { + "malleolus_med_l": _lm("malleolus_med_l", x_m=-0.12, y_m=0.07, z_m=0.13), + "ball_l": _lm("ball_l", x_m=-0.12, y_m=-0.06, z_m=0.04), + "achilles_l": _lm("achilles_l", x_m=-0.09, y_m=0.10, z_m=0.10), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + for lid in ("malleolus_med_l", "ball_l", "achilles_l"): + role = next(r for r in payload["roles"] if r["id"] == lid) + assert role["measured"] is not None + assert role["suggested"] == "hold_priors" + + +def test_d10_gastroc_ignore_delta_x(tmp_path: Path) -> None: + """D10 / B36: gastroc_med_l large delta.x and dy=dz=0 → hold_priors (ignore X).""" + report = _write_report( + tmp_path / "report.json", + _report( + { + "gastroc_med_l": _lm("gastroc_med_l", x_m=-0.40, y_m=0.0217, z_m=0.5717), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + gastroc = next(r for r in payload["roles"] if r["id"] == "gastroc_med_l") + assert gastroc["delta_mm"] is not None + assert abs(gastroc["delta_mm"]["x"]) >= 1.0 + assert gastroc["delta_mm"]["y"] == pytest.approx(0.0, abs=1e-6) + assert gastroc["delta_mm"]["z"] == pytest.approx(0.0, abs=1e-6) + assert gastroc["suggested"] == "hold_priors" + + +def test_d11_capsule_p0_soft_adjust(tmp_path: Path) -> None: + """D11 / B39: capsule center=null + measured Y Δ ≥1 mm → soft_adjust (not skip).""" + report = _write_report( + tmp_path / "report.json", + _report( + { + "gastroc_med_l": _lm("gastroc_med_l", x_m=-0.0976, y_m=0.08, z_m=0.5717), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_leg_foot_compare(report, recipe, tmp_path / "cmp", force=True) + gastroc = next(r for r in payload["roles"] if r["id"] == "gastroc_med_l") + assert gastroc["recipe"] is not None + assert gastroc["recipe"]["kind"] == "capsule" + assert gastroc["suggested"] == "soft_adjust" + + +def test_f1_mcp_catalog_51() -> None: + """F1: TOOL_NAMES 51 and leg-foot-compare tool present.""" + assert "mesh_proportion_blockout_leg_foot_compare" in TOOL_NAMES + assert len(TOOL_NAMES) == 51 + + +def test_f2_cli_contains_verb() -> None: + """F2: src/meshops/cli.py contains blockout-leg-foot-compare.""" + cli = Path("src/meshops/cli.py").read_text(encoding="utf-8") + assert "blockout-leg-foot-compare" in cli + + +def test_sidecar_unlinked_when_no_finite_ids(tmp_path: Path) -> None: + """--force with no finite leg/foot ids must not leave a stale sidecar.""" + report_hit = _write_report( + tmp_path / "hit.json", + _report({"gastroc_med_l": _lm("gastroc_med_l", x_m=-0.10, z_m=0.50)}), + ) + report_miss = _write_report(tmp_path / "miss.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + out = tmp_path / "cmp" + run_blockout_leg_foot_compare(report_hit, recipe, out, force=True) + assert (out / "leg_foot_metrics.json").is_file() + run_blockout_leg_foot_compare(report_miss, recipe, out, force=True) + assert not (out / "leg_foot_metrics.json").is_file() + + +def test_sidecar_leg_foot_metrics_when_ids_present(tmp_path: Path) -> None: + """Compare writes leg_foot_metrics.json when at least one v1 id is finite.""" + report = _write_report( + tmp_path / "report.json", + _report({"gastroc_med_l": _lm("gastroc_med_l", x_m=-0.10, z_m=0.50)}), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + out = tmp_path / "cmp" + payload = run_blockout_leg_foot_compare(report, recipe, out, force=True) + sidecar = out / "leg_foot_metrics.json" + assert sidecar.is_file() + metrics = json.loads(sidecar.read_text(encoding="utf-8")) + assert metrics["ank_bottom_vs_heel_bottom_m"] == pytest.approx(0.03803, abs=1e-4) + assert payload["leg_foot_metrics"]["ank_bottom_vs_heel_bottom_m"] == pytest.approx( + 0.03803, 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_leg_foot_compare + + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = mesh_proportion_blockout_leg_foot_compare( + tmp_path, + report=str(report), + recipe=str(recipe), + out=str(tmp_path / "cmp"), + force=True, + ) + assert payload["ok"] is True + assert payload["region"] == "leg_foot" + assert (tmp_path / "cmp" / "leg_foot_compare.json").is_file() + + +def test_f4_honesty_token() -> None: + """F4: LEG_FOOT_COMPARE_HONESTY in honesty.py.""" + from meshops.proportion import honesty as honesty_mod + + assert LEG_FOOT_COMPARE_HONESTY == "proportion_leg_foot_compare_not_mesh_or_print_success" + assert hasattr(honesty_mod, "LEG_FOOT_COMPARE_HONESTY") diff --git a/tests/test_proportion_leg_foot_landmarks.py b/tests/test_proportion_leg_foot_landmarks.py new file mode 100644 index 0000000..79b413b --- /dev/null +++ b/tests/test_proportion_leg_foot_landmarks.py @@ -0,0 +1,216 @@ +"""Track 0127 — leg/ankle/foot landmark ids / template blanks / fuse XYZ (offline). + +Authoring measurement only (LEG_FOOT_COMPARE_HONESTY / CAPTURE_HONESTY). +Not mesh or print success. Schema report stay 1.2.0. MCP 51 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, +) + +_LEG_FOOT_FRONT_IDS: tuple[str, ...] = ( + "malleolus_med_l", + "malleolus_med_r", + "malleolus_lat_l", + "malleolus_lat_r", + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "ball_l", + "ball_r", +) + +_LEG_FOOT_LEFT_IDS: tuple[str, ...] = ( + "gastroc_med_l", + "arch_apex_l", + "achilles_l", + "malleolus_med_l", +) + +_LEG_FOOT_BACK_IDS: tuple[str, ...] = ( + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "achilles_l", + "achilles_r", +) + +_LEG_FOOT_NEW_IDS: tuple[str, ...] = ( + "gastroc_med_l", + "gastroc_med_r", + "gastroc_lat_l", + "gastroc_lat_r", + "malleolus_med_l", + "malleolus_med_r", + "malleolus_lat_l", + "malleolus_lat_r", + "arch_apex_l", + "arch_apex_r", + "achilles_l", + "achilles_r", + "ball_l", + "ball_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_leg_foot_ids_in_known() -> None: + """A1: frozen v1 form-read ids are known (membership in, not ==).""" + for lid in _LEG_FOOT_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 gastroc/achilles.""" + for lid in _LEG_FOOT_FRONT_IDS: + assert lid in _FRONT_LANDMARK_KEYS + for lid in _LEG_FOOT_LEFT_IDS: + assert lid in _LEFT_LANDMARK_KEYS + for lid in _LEG_FOOT_BACK_IDS: + assert lid in _BACK_LANDMARK_KEYS + + +def test_a3_missing_id_no_invent_y() -> None: + """A3: missing/unknown 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), + "gastroc_med_l": _lm2("gastroc_med_l", 35.0, 130.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front}, height_m=1.72) + assert "gastroc_med_l" in out + assert out["gastroc_med_l"].x_m is not None + assert out["gastroc_med_l"].z_m is not None + assert out["gastroc_med_l"].y is None + assert out["gastroc_med_l"].y_m is None + assert "arch_apex_l" not in out + assert "achilles_l" not in out + + +def test_a4_leg_foot_have_ids_hold() -> None: + """A4: 0013/0024 knee/ankle/calf/heel/toe/foot DEPTH stay known.""" + assert "knee_l" in KNOWN_LANDMARK_IDS + assert "ankle_r" in KNOWN_LANDMARK_IDS + assert "calf_front" in KNOWN_LANDMARK_IDS + assert "heel_l" in KNOWN_LANDMARK_IDS + assert "toe_r" in KNOWN_LANDMARK_IDS + assert "foot_back" in KNOWN_LANDMARK_IDS + assert "knee_l" in _FRONT_LANDMARK_KEYS + assert "ankle_r" in _FRONT_LANDMARK_KEYS + assert "calf_front" in _LEFT_LANDMARK_KEYS + assert "heel_l" in _FRONT_LANDMARK_KEYS + assert "toe_r" in _FRONT_LANDMARK_KEYS + assert "foot_back" in _LEFT_LANDMARK_KEYS + + +def test_a5_no_gastroc_front_depth_pair() -> None: + """A5: do not add gastroc_front/back DEPTH_PAIRS.""" + flat = {lid for triple in DEPTH_PAIRS for lid in triple} + assert "gastroc_front" not in flat + assert "gastroc_back" not in flat + assert "gastroc_front" not in KNOWN_LANDMARK_IDS + + +def test_a6_asis_sternum_eye_still_known() -> None: + """A6: 0126 asis_l, 0125 sternum_mid, 0124 eye_l still in KNOWN.""" + assert "asis_l" in KNOWN_LANDMARK_IDS + 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_gastroc_xz() -> None: + """Back view fills X/Z for gastroc; 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), + "gastroc_med_l": _lm2("gastroc_med_l", 70.0, 140.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front, "back": back}, height_m=1.72) + assert "gastroc_med_l" in out + assert out["gastroc_med_l"].x is not None + assert out["gastroc_med_l"].z is not None + assert out["gastroc_med_l"].x_m is not None + assert out["gastroc_med_l"].z_m is not None + assert out["gastroc_med_l"].y is None + assert out["gastroc_med_l"].y_m is None + # camera_back inverts X so larger image-x (right of back photo) → MeshOps -X + assert out["gastroc_med_l"].x < 0.0 + + +def test_a_left_view_sets_leg_foot_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), + "gastroc_med_l": _lm2("gastroc_med_l", 35.0, 130.0), + }, + ) + left = ViewLandmarks( + view="left", + width_px=100, + height_px=200, + facing_direction="camera_left", + landmarks={ + "calf_front": _lm2("calf_front", 70.0, 130.0), + "calf_back": _lm2("calf_back", 30.0, 130.0), + "gastroc_med_l": _lm2("gastroc_med_l", 25.0, 130.0), + "arch_apex_l": _lm2("arch_apex_l", 40.0, 185.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front, "left": left}, height_m=1.72) + assert out["gastroc_med_l"].y is not None + assert out["gastroc_med_l"].y_m is not None + assert out["arch_apex_l"].y is not None + assert out["arch_apex_l"].y_m is not None diff --git a/tests/test_proportion_leg_foot_soft_consume.py b/tests/test_proportion_leg_foot_soft_consume.py new file mode 100644 index 0000000..a4550ba --- /dev/null +++ b/tests/test_proportion_leg_foot_soft_consume.py @@ -0,0 +1,194 @@ +"""Track 0127 — soft new-id calf_cyl p0 Y + arch Z (no 0108/0096/0071/0042/0056 retune). + +Does not weaken 0108 T* / 0096 calf / 0042 C_* 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 ( + CALF_BELLY_LAT_FRAC, + CALF_BELLY_REAR_FRAC, + CALF_BELLY_SCALE, + CALF_DIST_SHAFT_SCALE, + CALF_SPLIT_T, + build_blockout_recipe, +) +from meshops.proportion.extremity_recipe import ( + ANK_RY_FRAC_HALF_W, + ANK_RZ_FRAC_HALF_W, + ARCH_SOFT_RY_FRAC_HALF_DEPTH, + BALL_SOFT_RY_FRAC_HALF_DEPTH, + TOE_BALL_NEST_FRAC, + TOE_TIP_PAD_RY_FRAC, +) +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_gastroc_y_after_b6() -> None: + """E1: measured gastroc_med_l Y → calf_cyl_l.p0[1] tracks measured after B6. + + B32/B33: write after B6 call then _calf_split_mid into cyl.p1 and taper.p0. + Negative Y catches erroneous abs(); mid must sit at CALF_SPLIT_T. + """ + report = _product_class_report() + measured_y = -0.04 + report.landmarks_xyz["gastroc_med_l"] = _lm( + "gastroc_med_l", x_m=-0.10, y_m=measured_y, z_m=0.57 + ) + pkg = _emit(report) + cyl = next(p for p in pkg.parts if p.name == "RECIPE_calf_cyl_l") + taper = next(p for p in pkg.parts if p.name == "RECIPE_calf_taper_dist_l") + assert cyl.p0 is not None and cyl.p1 is not None + assert taper.p0 is not None and taper.p1 is not None + assert float(cyl.p0[1]) == pytest.approx(measured_y, abs=1e-6) + assert float(cyl.p0[1]) < 0.0 + assert float(cyl.p1[1]) == pytest.approx(float(taper.p0[1]), abs=1e-6) + expected_mid_y = float(cyl.p0[1]) + CALF_SPLIT_T * (float(taper.p1[1]) - float(cyl.p0[1])) + assert float(cyl.p1[1]) == pytest.approx(expected_mid_y, abs=1e-6) + assert CALF_SPLIT_T == 0.42 + assert any("measured calf_cyl y=" in m for m in pkg.messages) + + +def test_e2_absent_ids_prior_path() -> None: + """E2: absent gastroc/arch_apex ids → calf_cyl p0 Y / arch Z match 0096/0108 prior.""" + report = _product_class_report() + pkg = _emit(report) + cyl = next(p for p in pkg.parts if p.name == "RECIPE_calf_cyl_l") + arch = next(p for p in pkg.parts if p.name == "RECIPE_arch_soft_l") + assert cyl.p0 is not None + prior_y = float(cyl.p0[1]) + assert prior_y != pytest.approx(0.12, abs=1e-3) + assert arch.center is not None + prior_z = float(arch.center[2]) + assert prior_z != pytest.approx(0.09, abs=1e-3) + assert not any("measured calf_cyl y=" in m for m in pkg.messages) + assert not any("measured arch z=" in m for m in pkg.messages) + + +def test_e3_const_hold() -> None: + """E3: 0108/0096/0071 hold — B13 exact-equality.""" + assert ANK_RY_FRAC_HALF_W == 0.78 + assert TOE_BALL_NEST_FRAC == 0.52 + assert TOE_TIP_PAD_RY_FRAC == 0.55 + assert ARCH_SOFT_RY_FRAC_HALF_DEPTH == 0.18 + assert BALL_SOFT_RY_FRAC_HALF_DEPTH == 0.16 + assert CALF_BELLY_SCALE == 1.18 + assert CALF_DIST_SHAFT_SCALE == 0.80 + assert CALF_SPLIT_T == 0.42 + assert CALF_BELLY_LAT_FRAC == 0.30 + assert CALF_BELLY_REAR_FRAC == 0.42 + assert ANK_RZ_FRAC_HALF_W == 1.80 + + +def test_e4_front_only_malleolus_no_calf_y() -> None: + """E4: front-only measured malleolus → no Y write on calf_cyl.""" + report = _product_class_report() + baseline = _emit(report) + report.landmarks_xyz["malleolus_med_l"] = _lm("malleolus_med_l", x_m=-0.10, z_m=0.13) + moved = _emit(report) + base_c = next(p for p in baseline.parts if p.name == "RECIPE_calf_cyl_l") + new_c = next(p for p in moved.parts if p.name == "RECIPE_calf_cyl_l") + assert base_c.p0 is not None and new_c.p0 is not None + assert float(new_c.p0[1]) == pytest.approx(float(base_c.p0[1]), abs=1e-9) + + +def test_e5_have_ids_do_not_change_calf_or_ank() -> None: + """E5: calf_front/ankle_l/heel_l finite must not change ANK_RY / CALF_BELLY emit.""" + report = _product_class_report() + baseline = _emit(report) + report.landmarks_xyz["calf_front"] = _lm("calf_front", x_m=-0.09, y_m=-0.05, z_m=0.45) + report.landmarks_xyz["ankle_l"] = _lm("ankle_l", x_m=-0.09, y_m=0.07, z_m=0.13) + report.landmarks_xyz["heel_l"] = _lm("heel_l", x_m=-0.09, y_m=0.09, z_m=0.05) + moved = _emit(report) + assert ANK_RY_FRAC_HALF_W == 0.78 + assert CALF_BELLY_SCALE == 1.18 + base_c = next(p for p in baseline.parts if p.name == "RECIPE_calf_cyl_l") + new_c = next(p for p in moved.parts if p.name == "RECIPE_calf_cyl_l") + assert base_c.p0 is not None and new_c.p0 is not None + assert float(new_c.p0[1]) == pytest.approx(float(base_c.p0[1]), abs=1e-6) + assert float(new_c.radius_m or 0.0) == pytest.approx(float(base_c.radius_m or 0.0), abs=1e-6) + base_a = next(p for p in baseline.parts if p.name == "RECIPE_ank_foot_l") + new_a = next(p for p in moved.parts if p.name == "RECIPE_ank_foot_l") + assert base_a.rx_m is not None and new_a.rx_m is not None + assert float(new_a.rx_m) == pytest.approx(float(base_a.rx_m), abs=1e-9) + + +def test_e6_measured_malleolus_x_does_not_change_ank_rx() -> None: + """E6: measured malleolus_med_l X must not change ank_foot rx vs C_foot_width.""" + report = _product_class_report() + baseline = _emit(report) + report.landmarks_xyz["malleolus_med_l"] = _lm("malleolus_med_l", x_m=-0.20, y_m=0.07, z_m=0.13) + moved = _emit(report) + base_a = next(p for p in baseline.parts if p.name == "RECIPE_ank_foot_l") + new_a = next(p for p in moved.parts if p.name == "RECIPE_ank_foot_l") + assert base_a.rx_m is not None and new_a.rx_m is not None + assert float(new_a.rx_m) == pytest.approx(float(base_a.rx_m), abs=1e-9) + assert base_a.center is not None and new_a.center is not None + assert float(new_a.center[0]) == pytest.approx(float(base_a.center[0]), abs=1e-9) + + +def test_e7_one_side_measured_y_after_b6() -> None: + """E7: measured Y on one side different from contralateral prior still tracks.""" + report = _product_class_report() + baseline = _emit(report) + base_r = next(p for p in baseline.parts if p.name == "RECIPE_calf_cyl_r") + assert base_r.p0 is not None + prior_r = float(base_r.p0[1]) + measured_y = 0.18 + assert measured_y != pytest.approx(prior_r, abs=1e-3) + report.landmarks_xyz["gastroc_med_l"] = _lm( + "gastroc_med_l", x_m=-0.10, y_m=measured_y, z_m=0.57 + ) + pkg = _emit(report) + cyl_l = next(p for p in pkg.parts if p.name == "RECIPE_calf_cyl_l") + cyl_r = next(p for p in pkg.parts if p.name == "RECIPE_calf_cyl_r") + assert cyl_l.p0 is not None and cyl_r.p0 is not None + assert float(cyl_l.p0[1]) == pytest.approx(measured_y, abs=1e-6) + assert float(cyl_r.p0[1]) == pytest.approx(prior_r, abs=1e-6) + + +def test_e8_measured_arch_z_after_append() -> None: + """E8: measured arch_apex_l Z → arch_soft_l.center[2] tracks after arch write.""" + report = _product_class_report() + measured_z = 0.09 + report.landmarks_xyz["arch_apex_l"] = _lm("arch_apex_l", x_m=-0.09, y_m=-0.01, z_m=measured_z) + pkg = _emit(report) + arch = next(p for p in pkg.parts if p.name == "RECIPE_arch_soft_l") + assert arch.center is not None + assert float(arch.center[2]) == pytest.approx(measured_z, abs=1e-6) + assert any("measured arch z=" in m for m in pkg.messages) + + +def test_e_mcp_catalog_51() -> None: + assert len(TOOL_NAMES) == 51 + assert "mesh_proportion_blockout_leg_foot_compare" in TOOL_NAMES diff --git a/tests/test_proportion_limb_shaft_form_plus.py b/tests/test_proportion_limb_shaft_form_plus.py index 6068267..4e45702 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) == 50 + assert len(TOOL_NAMES) == 51 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 0a36a1e..a9d7d07 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) == 50 + assert len(TOOL_NAMES) == 51 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 a3fc678..2d076bf 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) == 50 + assert len(TOOL_NAMES) == 51 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 34c81eb..3b89221 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) == 50" in mcp_test + assert "len(TOOL_NAMES) == 51" 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) == 50 + assert len(TOOL_NAMES) == 51 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) == 50 + assert len(TOOL_NAMES) == 51 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 312d85a..af27cf3 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) == 50 + assert len(TOOL_NAMES) == 51 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 64416a0..dcf4562 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) == 50 + assert len(TOOL_NAMES) == 51 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 7de35dd..c1a91ca 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) == 50 + assert len(TOOL_NAMES) == 51 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 42a7a65..f5c6ff5 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) == 50 + assert len(TOOL_NAMES) == 51 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 e2e0c5e..dca4be0 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) == 50 + assert len(TOOL_NAMES) == 51 def test_f2_cli_contains_verb() -> None: diff --git a/tests/test_proportion_torso_continuous.py b/tests/test_proportion_torso_continuous.py index 8181f34..583b89d 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) == 50 + assert len(TOOL_NAMES) == 51 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 b873c7d..5e7f9b7 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) == 50 + assert len(TOOL_NAMES) == 51 diff --git a/tests/test_proportion_torso_thoracic_front.py b/tests/test_proportion_torso_thoracic_front.py index 8c949a4..a5dc90c 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) == 50 + assert len(TOOL_NAMES) == 51 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 0c36149..0528d78 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) == 50 + assert len(TOOL_NAMES) == 51 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 d3dcbd1..4449bb5 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) == 50 + assert len(TOOL_NAMES) == 51 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 d298e01..34e0ed6 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) == 50 + assert len(TOOL_NAMES) == 51 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 03d2c43..1b1a553 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) == 50 + assert len(TOOL_NAMES) == 51 def test_t7_no_hide_cli_command() -> None: