diff --git a/src/meshops/cli.py b/src/meshops/cli.py index ad0199c..659c998 100644 --- a/src/meshops/cli.py +++ b/src/meshops/cli.py @@ -126,7 +126,9 @@ "blockout-leg-foot-compare photo vs RECIPE vs optional scene " "(0127; LEG_FOOT_COMPARE_HONESTY — N6); " "blockout-girdle-compare photo vs RECIPE vs optional scene " - "(0128; GIRDLE_COMPARE_HONESTY — N6). " + "(0128; GIRDLE_COMPARE_HONESTY — N6); " + "blockout-arm-hand-compare photo vs RECIPE vs optional scene " + "(0129; ARM_HAND_COMPARE_HONESTY — N6). " "Optional: meshops[proportion] (Pillow)." ), add_completion=False, @@ -3620,5 +3622,71 @@ def proportion_blockout_girdle_compare_cmd( raise typer.Exit(0) +@proportion_app.command("blockout-arm-hand-compare") +def proportion_blockout_arm_hand_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 arm_hand_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 arm_hand_compare.json", + ), + json_out: bool = typer.Option(False, "--json", help="Emit machine result JSON"), +) -> None: + """Compare Package A arm/hand landmarks vs RECIPE vs optional live scene. + + Authoring QA only — not mesh or print success (ARM_HAND_COMPARE_HONESTY). + """ + from meshops.proportion.arm_hand_compare import run_blockout_arm_hand_compare + from meshops.proportion.errors import ProportionError + from meshops.proportion.honesty import ARM_HAND_COMPARE_HONESTY + + try: + payload = run_blockout_arm_hand_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-arm-hand-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: {ARM_HAND_COMPARE_HONESTY}") + typer.echo("blockout-arm-hand-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 38b3400..6a2ef17 100644 --- a/src/meshops/mcp/server.py +++ b/src/meshops/mcp/server.py @@ -76,6 +76,7 @@ "mesh_proportion_blockout_hip_glute_compare", "mesh_proportion_blockout_leg_foot_compare", "mesh_proportion_blockout_girdle_compare", + "mesh_proportion_blockout_arm_hand_compare", } ) @@ -1067,4 +1068,26 @@ def mesh_proportion_blockout_girdle_compare( force=force, ) + @mcp.tool() + def mesh_proportion_blockout_arm_hand_compare( + report: str, + recipe: str, + out: str, + scene_dump: str | None = None, + force: bool = False, + ) -> dict[str, Any]: + """Compare Package A arm/hand landmarks vs RECIPE vs optional live scene dump. + + Authoring QA only — proportion_arm_hand_compare_not_mesh_or_print_success. + Not mesh or print success. Raises ProportionError on hard failures. + """ + return T.mesh_proportion_blockout_arm_hand_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 e23af45..d7410b4 100644 --- a/src/meshops/mcp/tools.py +++ b/src/meshops/mcp/tools.py @@ -1484,3 +1484,30 @@ def mesh_proportion_blockout_girdle_compare( scene_dump=_resolve_tool_path(scene_dump, work_root) if scene_dump else None, force=force, ) + + +def mesh_proportion_blockout_arm_hand_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 — ARM_HAND_COMPARE_HONESTY.""" + from meshops.proportion.arm_hand_compare import run_blockout_arm_hand_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_arm_hand_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/arm_hand_compare.py b/src/meshops/proportion/arm_hand_compare.py new file mode 100644 index 0000000..23ffff4 --- /dev/null +++ b/src/meshops/proportion/arm_hand_compare.py @@ -0,0 +1,699 @@ +"""Arm/hand landmark compare: photo vs RECIPE vs optional live scene (track 0129). + +Authoring QA only — ARM_HAND_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 ARM_HAND_COMPARE_HONESTY +from meshops.proportion.models import LandmarkXYZ, ProportionReport + +ARM_HAND_COMPARE_SCHEMA_VERSION: Final[Literal["1.0.0"]] = "1.0.0" +ARM_HAND_COMPARE_JSON: Final[str] = "arm_hand_compare.json" +ARM_HAND_METRICS_JSON: Final[str] = "arm_hand_metrics.json" +ARM_HAND_COMPARE_ROLES: Final[tuple[str, ...]] = ( + "humeral_head_l", + "humeral_head_r", + "bi_belly_l", + "bi_belly_r", + "tri_belly_l", + "tri_belly_r", + "olecranon_l", + "olecranon_r", + "fa_belly_l", + "fa_belly_r", + "palm_center_l", + "palm_center_r", + "thumb_cmc_l", + "thumb_cmc_r", + "thumb_tip_l", + "thumb_tip_r", + "mcp_index_l", + "mcp_index_r", + "mcp_pinky_l", + "mcp_pinky_r", + "shoulder_l", + "shoulder_r", + "elbow_l", + "elbow_r", + "wrist_l", + "wrist_r", +) +SUGGESTED_ACTIONS: Final[frozenset[str]] = frozenset( + {"skip", "hold_priors", "soft_adjust", "session_arm_hand", "remake_0111"} +) +FORM_READ_TOKENS: Final[frozenset[str]] = frozenset( + { + "bi_front_past", + "tri_rear_past", + "fa_stepped", + "thumb_not_forward", + "missing_id", + } +) +BI_FRONT_PAST_FLAG_M: Final[float] = 0.006 +TRI_REAR_PAST_FLAG_M: Final[float] = 0.006 +FA_STEPPED_RATIO_MAX: Final[float] = 0.78 +THUMB_FORWARD_Y_MAX: Final[float] = -0.40 + +_ROLE_PART: dict[str, str] = { + "humeral_head_l": "RECIPE_limb_upper_arm_l", + "humeral_head_r": "RECIPE_limb_upper_arm_r", + "bi_belly_l": "RECIPE_bicep_soft_l", + "bi_belly_r": "RECIPE_bicep_soft_r", + "tri_belly_l": "RECIPE_triceps_soft_l", + "tri_belly_r": "RECIPE_triceps_soft_r", + "olecranon_l": "RECIPE_elbow_soft_l", + "olecranon_r": "RECIPE_elbow_soft_r", + "fa_belly_l": "RECIPE_limb_forearm_l", + "fa_belly_r": "RECIPE_limb_forearm_r", + "palm_center_l": "RECIPE_palm_l", + "palm_center_r": "RECIPE_palm_r", + "thumb_cmc_l": "RECIPE_thumb_soft_0_l", + "thumb_cmc_r": "RECIPE_thumb_soft_0_r", + "thumb_tip_l": "RECIPE_thumb_soft_1_l", + "thumb_tip_r": "RECIPE_thumb_soft_1_r", + "mcp_index_l": "RECIPE_finger_index_0_l", + "mcp_index_r": "RECIPE_finger_index_0_r", + "mcp_pinky_l": "RECIPE_finger_pinky_0_l", + "mcp_pinky_r": "RECIPE_finger_pinky_0_r", + "shoulder_l": "RECIPE_deltoid_soft_l", + "shoulder_r": "RECIPE_deltoid_soft_r", + "elbow_l": "RECIPE_elbow_soft_l", + "elbow_r": "RECIPE_elbow_soft_r", + "wrist_l": "RECIPE_dist_soft_forearm_l", + "wrist_r": "RECIPE_dist_soft_forearm_r", +} + +# B36/B40: soft_adjust only for new-id bi/tri belly Y and Z. +_SOFT_ADJUST_IDS: Final[frozenset[str]] = frozenset( + {"bi_belly_l", "bi_belly_r", "tri_belly_l", "tri_belly_r"} +) + +# B39: role-mapped capsule endpoints (not 0127 always-p0; not 0126 center-skip). +_ENDPOINT: dict[str, Literal["p0", "p1", "midpoint"]] = { + "humeral_head_l": "p0", + "humeral_head_r": "p0", + "fa_belly_l": "midpoint", + "fa_belly_r": "midpoint", + "thumb_cmc_l": "p0", + "thumb_cmc_r": "p0", + "thumb_tip_l": "p1", + "thumb_tip_r": "p1", + "mcp_index_l": "p0", + "mcp_index_r": "p0", + "mcp_pinky_l": "p0", + "mcp_pinky_r": "p0", +} + +_KNOB: dict[str, str] = { + "humeral_head_l": "UA_PROX_SHAFT_SCALE", + "humeral_head_r": "UA_PROX_SHAFT_SCALE", + "bi_belly_l": "BICEP_FRONT_PAST_M", + "bi_belly_r": "BICEP_FRONT_PAST_M", + "tri_belly_l": "TRICEP_REAR_PAST_M", + "tri_belly_r": "TRICEP_REAR_PAST_M", + "olecranon_l": "ELBOW_SOFT_SCALE", + "olecranon_r": "ELBOW_SOFT_SCALE", + "fa_belly_l": "FA_PROX_SHAFT_SCALE", + "fa_belly_r": "FA_PROX_SHAFT_SCALE", + "palm_center_l": "_PALM_WIDTH_FRAC_HAND", + "palm_center_r": "_PALM_WIDTH_FRAC_HAND", + "thumb_cmc_l": "_THUMB_PALM_PITCH", + "thumb_cmc_r": "_THUMB_PALM_PITCH", + "thumb_tip_l": "_THUMB_PALM_PITCH", + "thumb_tip_r": "_THUMB_PALM_PITCH", + "mcp_index_l": "_FINGER_R_SCALES_SEG", + "mcp_index_r": "_FINGER_R_SCALES_SEG", + "mcp_pinky_l": "_FINGER_R_SCALES_SEG", + "mcp_pinky_r": "_FINGER_R_SCALES_SEG", + "shoulder_l": "DELT_RY_FRAC", + "shoulder_r": "DELT_RY_FRAC", + "elbow_l": "ELBOW_SOFT_SCALE", + "elbow_r": "ELBOW_SOFT_SCALE", + "wrist_l": "FA_DIST_SHAFT_SCALE", + "wrist_r": "FA_DIST_SHAFT_SCALE", +} + +_KNOWN_DUMP_NAMES: Final[frozenset[str]] = frozenset( + { + "RECIPE_limb_upper_arm_l", + "RECIPE_limb_upper_arm_r", + "RECIPE_arm_taper_dist_ua_l", + "RECIPE_arm_taper_dist_ua_r", + "RECIPE_limb_forearm_l", + "RECIPE_limb_forearm_r", + "RECIPE_arm_taper_dist_fa_l", + "RECIPE_arm_taper_dist_fa_r", + "RECIPE_bicep_soft_l", + "RECIPE_bicep_soft_r", + "RECIPE_triceps_soft_l", + "RECIPE_triceps_soft_r", + "RECIPE_elbow_soft_l", + "RECIPE_elbow_soft_r", + "RECIPE_dist_soft_forearm_l", + "RECIPE_dist_soft_forearm_r", + "RECIPE_palm_l", + "RECIPE_palm_r", + "RECIPE_thumb_soft_0_l", + "RECIPE_thumb_soft_0_r", + "RECIPE_thumb_soft_1_l", + "RECIPE_thumb_soft_1_r", + "RECIPE_finger_index_0_l", + "RECIPE_finger_index_0_r", + "RECIPE_finger_pinky_0_l", + "RECIPE_finger_pinky_0_r", + "RECIPE_deltoid_soft_l", + "RECIPE_deltoid_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 _mapped_endpoint(part: dict[str, Any], role_id: str) -> list[float] | None: + """B39: UA/FA/thumb/finger use role-mapped p0/p1/midpoint — never 0127 always-p0.""" + which = _ENDPOINT.get(role_id) + if which == "p0": + return _as_vec3(part.get("p0")) + if which == "p1": + return _as_vec3(part.get("p1")) + if which == "midpoint": + return _midpoint(part.get("p0"), part.get("p1")) + return None + + +def extract_recipe_arm_hand_part(parts: list[Any], role_id: str) -> dict[str, Any] | None: + """Extract one arm/hand role from RECIPE parts (B26/B39 mapped capsule endpoints).""" + 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 "") + mapped = _mapped_endpoint(match, role_id) + center = mapped + if center is None: + center = _as_vec3(match.get("center")) + if center is None: + center = _midpoint(match.get("p0"), match.get("p1")) + if kind in ("capsule", "cylinder") and center is None: + return None + 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"), + "placement": match.get("placement"), + } + + +class ArmHandMetrics(BaseModel): + """Sidecar arm/hand 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 = ARM_HAND_COMPARE_HONESTY + bicep_front_past_m: float | None = None + triceps_rear_past_m: float | None = None + ua_prox_r_m: float | None = None + ua_dist_r_m: float | None = None + fa_prox_r_m: float | None = None + fa_dist_r_m: float | None = None + bicep_rx_m: float | None = None + triceps_rx_m: float | None = None + palm_rx_m: float | None = None + palm_ry_m: float | None = None + y_m: dict[str, float | None] = Field(default_factory=dict) + + +class ArmHandCompareCoord(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 ArmHandCompareDelta(BaseModel): + model_config = ConfigDict(extra="forbid") + + x: float | None = None + y: float | None = None + z: float | None = None + + +class ArmHandCompareRole(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + measured: ArmHandCompareCoord | None = None + recipe: dict[str, Any] | None = None + live: dict[str, Any] | None = None + delta_mm: ArmHandCompareDelta | 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_arm_hand", "remake_0111"] = ( + "skip" + ) + + +class ArmHandComparePackage(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: Literal["1.0.0"] = ARM_HAND_COMPARE_SCHEMA_VERSION + honesty: str = ARM_HAND_COMPARE_HONESTY + region: Literal["arm_hand"] = "arm_hand" + ok: bool = False + roles: list[ArmHandCompareRole] = Field(default_factory=list) + messages: list[str] = Field(default_factory=list) + arm_hand_metrics: ArmHandMetrics | 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 _capsule_mid_y_r(part: dict[str, Any] | None) -> tuple[float | None, float | None]: + if part is None: + return None, None + mid = _midpoint(part.get("p0"), part.get("p1")) + r = _as_float(part.get("radius_m")) + if mid is None: + return None, r + return mid[1], r + + +def _bicep_front_past( + bicep: dict[str, Any] | None, + ua: dict[str, Any] | None, +) -> float | None: + """B35: None unless bicep center/ry and UA r are finite.""" + if bicep is None or ua is None: + return None + center = _as_vec3(bicep.get("center")) + ry = _as_float(bicep.get("ry_m")) + mid_y, ua_r = _capsule_mid_y_r(ua) + if center is None or ry is None or mid_y is None or ua_r is None: + return None + shaft_front = mid_y - ua_r + return shaft_front - (center[1] - ry) + + +def _triceps_rear_past( + triceps: dict[str, Any] | None, + ua: dict[str, Any] | None, +) -> float | None: + if triceps is None or ua is None: + return None + center = _as_vec3(triceps.get("center")) + ry = _as_float(triceps.get("ry_m")) + mid_y, ua_r = _capsule_mid_y_r(ua) + if center is None or ry is None or mid_y is None or ua_r is None: + return None + shaft_rear = mid_y + ua_r + return (center[1] + ry) - shaft_rear + + +def build_arm_hand_metrics( + report: ProportionReport, + recipe: Any | None = None, +) -> ArmHandMetrics: + """Compute arm_hand_metrics from landmarks_xyz (+ optional RECIPE parts).""" + parts = _recipe_parts(recipe) + ua = _find_part(parts, "RECIPE_limb_upper_arm_l") or _find_part( + parts, "RECIPE_limb_upper_arm_r" + ) + ua_dist = _find_part(parts, "RECIPE_arm_taper_dist_ua_l") or _find_part( + parts, "RECIPE_arm_taper_dist_ua_r" + ) + fa = _find_part(parts, "RECIPE_limb_forearm_l") or _find_part(parts, "RECIPE_limb_forearm_r") + fa_dist = _find_part(parts, "RECIPE_arm_taper_dist_fa_l") or _find_part( + parts, "RECIPE_arm_taper_dist_fa_r" + ) + bicep = _find_part(parts, "RECIPE_bicep_soft_l") or _find_part(parts, "RECIPE_bicep_soft_r") + triceps = _find_part(parts, "RECIPE_triceps_soft_l") or _find_part( + parts, "RECIPE_triceps_soft_r" + ) + palm = _find_part(parts, "RECIPE_palm_l") or _find_part(parts, "RECIPE_palm_r") + + lms = report.landmarks_xyz + y_fields: dict[str, float | None] = {} + for lid in ARM_HAND_COMPARE_ROLES: + lm = lms.get(lid) + y_fields[lid] = _as_float(lm.y_m) if lm is not None else None + + return ArmHandMetrics( + bicep_front_past_m=_bicep_front_past(bicep, ua), + triceps_rear_past_m=_triceps_rear_past(triceps, ua), + ua_prox_r_m=_as_float(ua.get("radius_m")) if ua is not None else None, + ua_dist_r_m=_as_float(ua_dist.get("radius_m")) if ua_dist is not None else None, + fa_prox_r_m=_as_float(fa.get("radius_m")) if fa is not None else None, + fa_dist_r_m=_as_float(fa_dist.get("radius_m")) if fa_dist is not None else None, + bicep_rx_m=_as_float(bicep.get("rx_m")) if bicep is not None else None, + triceps_rx_m=_as_float(triceps.get("rx_m")) if triceps is not None else None, + palm_rx_m=_as_float(palm.get("rx_m")) if palm is not None else None, + palm_ry_m=_as_float(palm.get("ry_m")) if palm is not None else None, + y_m=y_fields, + ) + + +def _measured_coord(lm: LandmarkXYZ | None) -> ArmHandCompareCoord | 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 ArmHandCompareCoord( + 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, role_id: str) -> list[float] | None: + if recipe is None: + return None + mapped = _mapped_endpoint(recipe, role_id) + if mapped is not None: + return mapped + center = _as_vec3(recipe.get("center")) + if center is not None: + return center + return _midpoint(recipe.get("p0"), recipe.get("p1")) + + +def _delta_mm( + measured: ArmHandCompareCoord | None, + recipe: dict[str, Any] | None, + role_id: str, +) -> ArmHandCompareDelta | None: + if measured is None or recipe is None: + return None + # B39: capsule/cylinder roles use mapped p0/p1/midpoint (live center=null). + anchor = _anchor_xyz(recipe, role_id) + 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 ArmHandCompareDelta(x=dx, y=dy, z=dz) + + +def _suggest( + measured: ArmHandCompareCoord | None, + recipe: dict[str, Any] | None, + delta: ArmHandCompareDelta | None, + role_id: str, +) -> Literal["skip", "hold_priors", "soft_adjust", "session_arm_hand", "remake_0111"]: + if measured is None: + return "skip" + if recipe is None or _anchor_xyz(recipe, role_id) is None: + return "skip" + if role_id not in _SOFT_ADJUST_IDS: + return "hold_priors" + if delta is None: + return "hold_priors" + # B36/B40: Y/Z consume — ignore delta.x; fire on max(|dy|, |dz|). + 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="arm_hand_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="arm_hand_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="arm_hand_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="arm_hand_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="arm_hand_compare_failed", + details={"path": str(path), "index": i, "name": name}, + ) + out.append(part) + return out + + +def _has_finite_arm_hand(lms: dict[str, LandmarkXYZ]) -> bool: + for lid in ARM_HAND_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 _thumb_axis_y(parts: list[Any]) -> float | None: + for name in ("RECIPE_thumb_soft_0_l", "RECIPE_thumb_soft_0_r"): + part = _find_part(parts, name) + if part is None: + continue + p0 = _as_vec3(part.get("p0")) + p1 = _as_vec3(part.get("p1")) + if p0 is None or p1 is None: + continue + dy = p1[1] - p0[1] + dx = p1[0] - p0[0] + dz = p1[2] - p0[2] + length = math.sqrt(dx * dx + dy * dy + dz * dz) + if length < 1e-12: + continue + return dy / length + return None + + +def run_blockout_arm_hand_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 / ARM_HAND_COMPARE_JSON + if dest.exists() and not force: + raise ProportionError( + f"arm/hand compare exists: {dest} (pass --force)", + code="arm_hand_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] = [ + "ARM_HAND_COMPARE_HONESTY — authoring QA only; not mesh or print success", + ] + metrics = build_arm_hand_metrics(rep, recipe=pkg) + bi_flag = ( + metrics.bicep_front_past_m is not None and metrics.bicep_front_past_m < BI_FRONT_PAST_FLAG_M + ) + if bi_flag: + messages.append(f"bicep_front_past_m={metrics.bicep_front_past_m:.4f}") + tri_flag = ( + metrics.triceps_rear_past_m is not None + and metrics.triceps_rear_past_m < TRI_REAR_PAST_FLAG_M + ) + if tri_flag: + messages.append(f"triceps_rear_past_m={metrics.triceps_rear_past_m:.4f}") + fa_flag = False + if ( + metrics.fa_prox_r_m is not None + and metrics.fa_dist_r_m is not None + and metrics.fa_prox_r_m > 0.0 + ): + ratio = metrics.fa_dist_r_m / metrics.fa_prox_r_m + fa_flag = ratio <= FA_STEPPED_RATIO_MAX + if fa_flag: + messages.append(f"fa_stepped_ratio={ratio:.4f}") + thumb_y = _thumb_axis_y(parts) + thumb_flag = thumb_y is not None and thumb_y >= THUMB_FORWARD_Y_MAX + if thumb_flag: + messages.append(f"thumb_axis_y={thumb_y:.4f}") + + lms = rep.landmarks_xyz + roles: list[ArmHandCompareRole] = [] + for lid in ARM_HAND_COMPARE_ROLES: + measured = _measured_coord(lms.get(lid)) + rec = extract_recipe_arm_hand_part(parts, lid) + live = extract_recipe_arm_hand_part(live_parts, lid) if live_parts is not None else None + delta = _delta_mm(measured, rec, lid) + form: list[str] = [] + if measured is None: + form.append("missing_id") + if bi_flag and lid.startswith("bi_belly_"): + form.append("bi_front_past") + if tri_flag and lid.startswith("tri_belly_"): + form.append("tri_rear_past") + if fa_flag and lid.startswith("fa_belly_"): + form.append("fa_stepped") + if thumb_flag and lid.startswith("thumb_"): + form.append("thumb_not_forward") + 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( + ArmHandCompareRole( + 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 = ArmHandComparePackage( + ok=True, + roles=roles, + messages=messages, + arm_hand_metrics=metrics, + package_path=str(dest), + ) + dest.write_text(package.model_dump_json(indent=2) + "\n", encoding="utf-8") + sidecar = out_dir / ARM_HAND_METRICS_JSON + if _has_finite_arm_hand(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/assist.py b/src/meshops/proportion/assist.py index c5d095a..1bd4e54 100644 --- a/src/meshops/proportion/assist.py +++ b/src/meshops/proportion/assist.py @@ -174,6 +174,27 @@ "acromion_l", "acromion_r", "nape", + # 0129 arm/hand form-read (not Pose 13-22 / Hand Landmarker 21) + "humeral_head_l", + "humeral_head_r", + "bi_belly_l", + "bi_belly_r", + "tri_belly_l", + "tri_belly_r", + "olecranon_l", + "olecranon_r", + "fa_belly_l", + "fa_belly_r", + "palm_center_l", + "palm_center_r", + "thumb_cmc_l", + "thumb_cmc_r", + "thumb_tip_l", + "thumb_tip_r", + "mcp_index_l", + "mcp_index_r", + "mcp_pinky_l", + "mcp_pinky_r", } ) @@ -312,6 +333,46 @@ "nape", ) +# Frozen v1 arm/hand form-read ids (0129). Existing shoulder/elbow/wrist stay. +ARM_HAND_FRONT_LANDMARK_IDS: tuple[str, ...] = ( + "humeral_head_l", + "humeral_head_r", + "bi_belly_l", + "bi_belly_r", + "tri_belly_l", + "tri_belly_r", + "olecranon_l", + "olecranon_r", + "fa_belly_l", + "fa_belly_r", + "palm_center_l", + "palm_center_r", + "thumb_cmc_l", + "thumb_cmc_r", + "thumb_tip_l", + "thumb_tip_r", + "mcp_index_l", + "mcp_index_r", + "mcp_pinky_l", + "mcp_pinky_r", +) +ARM_HAND_LEFT_LANDMARK_IDS: tuple[str, ...] = ( + "humeral_head_l", + "bi_belly_l", + "tri_belly_l", + "olecranon_l", + "fa_belly_l", + "palm_center_l", + "thumb_cmc_l", + "thumb_tip_l", +) +ARM_HAND_BACK_LANDMARK_IDS: tuple[str, ...] = ( + "tri_belly_l", + "tri_belly_r", + "olecranon_l", + "olecranon_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 8f617e0..c2fc154 100644 --- a/src/meshops/proportion/blockout_recipe.py +++ b/src/meshops/proportion/blockout_recipe.py @@ -1863,6 +1863,50 @@ def _apply_measured_trap_yz( trap.placement = "full3d" +def _apply_measured_bi_tri_yz( + parts: list[RecipePart], + report: ProportionReport, + messages: list[str], +) -> None: + """0129: overlay measured bi/tri belly Y/Z onto bicep/triceps after 0063 rewrite. + + B32: do not abs measured Y. B33: next statement after muscle call L4778. + B34: find triceps by name RECIPE_triceps_soft_{side} (role is limb_segment). + """ + by_name = {p.name: p for p in parts} + for side in ("l", "r"): + bicep = by_name.get(f"RECIPE_bicep_soft_{side}") + if bicep is not None and bicep.center is not None and len(bicep.center) >= 3: + my = _measured_lm_m(report, f"bi_belly_{side}", y=True) + mz = _measured_lm_m(report, f"bi_belly_{side}", y=False) + if my is not None or mz is not None: + c = list(bicep.center) + if my is not None: + c[1] = my + messages.append(f"arm_hand: measured bicep y={my:.4f} ({side})") + if mz is not None: + c[2] = mz + messages.append(f"arm_hand: measured bicep z={mz:.4f} ({side})") + bicep.center = c + bicep.placement = "full3d" + + # B34: name-gate — live role is limb_segment, not triceps_soft. + tri = by_name.get(f"RECIPE_triceps_soft_{side}") + if tri is not None and tri.center is not None and len(tri.center) >= 3: + my = _measured_lm_m(report, f"tri_belly_{side}", y=True) + mz = _measured_lm_m(report, f"tri_belly_{side}", y=False) + if my is not None or mz is not None: + c = list(tri.center) + if my is not None: + c[1] = my + messages.append(f"arm_hand: measured triceps y={my:.4f} ({side})") + if mz is not None: + c[2] = mz + messages.append(f"arm_hand: measured triceps z={mz:.4f} ({side})") + tri.center = c + tri.placement = "full3d" + + def _apply_scap_plane( parts: list[RecipePart], report: ProportionReport, @@ -4776,6 +4820,8 @@ def build_blockout_recipe( # 0063: bicep + triceps (after profile + 0060/0061/0066/0074; before breast hang) _apply_arm_muscle_softs(parts, messages) + # 0129: measured bi/tri belly Y/Z after L1668 rewrite (B32/B33/B34 - do not abs). + _apply_measured_bi_tri_yz(parts, report, messages) # 0067 B4: athletic tear + sternum on dual breast_soft (before hang Z / tilt) _apply_breast_lower_pole_athletic(parts, report, resolved, template_applied, messages) @@ -7342,6 +7388,7 @@ def run_blockout_emit_setup( "_apply_glute_seat_mass", "_apply_head_pitch", "_apply_join_ready_overlaps", + "_apply_measured_bi_tri_yz", "_apply_measured_calf_cyl_y", "_apply_measured_trap_yz", "_apply_mid_back_plane", diff --git a/src/meshops/proportion/fuse.py b/src/meshops/proportion/fuse.py index 8fc1f9a..61ca896 100644 --- a/src/meshops/proportion/fuse.py +++ b/src/meshops/proportion/fuse.py @@ -17,6 +17,8 @@ from __future__ import annotations from meshops.proportion.assist import ( + ARM_HAND_BACK_LANDMARK_IDS, + ARM_HAND_LEFT_LANDMARK_IDS, FACE_LEFT_LANDMARK_IDS, GIRDLE_BACK_LANDMARK_IDS, GIRDLE_LEFT_LANDMARK_IDS, @@ -210,8 +212,8 @@ def fuse_xyz( xyz.z_m = z * height_m out[lid] = xyz - # 0125/0126/0127/0128: back-view X/Z for torso + hip/glute + leg/foot + girdle - # form-read ids (not DEPTH_PAIRS; Y from left). + # 0125/0126/0127/0128/0129: back-view X/Z for torso + hip/glute + leg/foot + + # girdle + arm/hand 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) @@ -227,6 +229,7 @@ def fuse_xyz( *HIP_GLUTE_BACK_LANDMARK_IDS, *LEG_FOOT_BACK_LANDMARK_IDS, *GIRDLE_BACK_LANDMARK_IDS, + *ARM_HAND_BACK_LANDMARK_IDS, ): src_lm = back.landmarks.get(lid) if src_lm is None: @@ -351,13 +354,14 @@ def fuse_xyz( mid.x_m = x_ref * height_m out[mid_id] = mid - # 0124/0125/0126/0127/0128: same-id left overlay Y. + # 0124/0125/0126/0127/0128/0129: same-id left overlay Y. for lid in ( *FACE_LEFT_LANDMARK_IDS, *TORSO_LEFT_LANDMARK_IDS, *HIP_GLUTE_LEFT_LANDMARK_IDS, *LEG_FOOT_LEFT_LANDMARK_IDS, *GIRDLE_LEFT_LANDMARK_IDS, + *ARM_HAND_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 5cc505e..4798de5 100644 --- a/src/meshops/proportion/honesty.py +++ b/src/meshops/proportion/honesty.py @@ -25,6 +25,7 @@ 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" GIRDLE_COMPARE_HONESTY = "proportion_girdle_compare_not_mesh_or_print_success" +ARM_HAND_COMPARE_HONESTY = "proportion_arm_hand_compare_not_mesh_or_print_success" TEMPLATE_HONESTY = "proportion_body_template_not_mesh_or_print_success" CONSTRAINT_HONESTY = "proportion_blockout_constraints_not_mesh_or_print_success" OPTIMIZE_HONESTY = "proportion_blockout_optimize_not_mesh_or_print_success" diff --git a/src/meshops/proportion/template.py b/src/meshops/proportion/template.py index a1a267c..122f57a 100644 --- a/src/meshops/proportion/template.py +++ b/src/meshops/proportion/template.py @@ -113,6 +113,27 @@ "scm_insert_r", "acromion_l", "acromion_r", + # 0129 arm/hand form-read (front) + "humeral_head_l", + "humeral_head_r", + "bi_belly_l", + "bi_belly_r", + "tri_belly_l", + "tri_belly_r", + "olecranon_l", + "olecranon_r", + "fa_belly_l", + "fa_belly_r", + "palm_center_l", + "palm_center_r", + "thumb_cmc_l", + "thumb_cmc_r", + "thumb_tip_l", + "thumb_tip_r", + "mcp_index_l", + "mcp_index_r", + "mcp_pinky_l", + "mcp_pinky_r", ) # Left profile: depth pairs + optional heel/breast hang. No toe_l/r (B1 — front only). @@ -170,6 +191,15 @@ "clav_med_l", "nape", "scm_origin_l", + # 0129 arm/hand Y / pride (never invent from front-only) + "humeral_head_l", + "bi_belly_l", + "tri_belly_l", + "olecranon_l", + "fa_belly_l", + "palm_center_l", + "thumb_cmc_l", + "thumb_tip_l", ) # Top-level edge_pairs stubs (sibling of views) — fill [[x0,y0],[x1,y1]]. @@ -242,6 +272,11 @@ "trap_lat_l", "trap_lat_r", "nape", + # 0129 arm/hand form-read (back — Package A currently empty here) + "tri_belly_l", + "tri_belly_r", + "olecranon_l", + "olecranon_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 6afaaba..3f7d983 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) == 52 + assert len(TOOL_NAMES) == 53 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 f289b33..cbe98d0 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) == 52 + assert len(TOOL_NAMES) == 53 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 7b240c4..bb2409b 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) == 52 + assert len(TOOL_NAMES) == 53 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 4bc1ad7..e716873 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) == 52 + assert len(TOOL_NAMES) == 53 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 909ae41..1cdde64 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) == 52 + assert len(TOOL_NAMES) == 53 def test_t7_no_limb_scale_cli_command() -> None: diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 9afe0db..413a261 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -79,13 +79,14 @@ async def _body() -> None: assert "mesh_proportion_blockout_hip_glute_compare" in names assert "mesh_proportion_blockout_leg_foot_compare" in names assert "mesh_proportion_blockout_girdle_compare" in names - assert len(names) == 52 + assert "mesh_proportion_blockout_arm_hand_compare" in names + assert len(names) == 53 _run(_body()) def test_mcp__proportion_tools_in_catalog() -> None: - """Explicit catalog freeze: proportion tools + open-setup; len == 52.""" + """Explicit catalog freeze: proportion tools + open-setup; len == 53.""" async def _body() -> None: server = build_server() @@ -119,24 +120,25 @@ async def _body() -> None: "mesh_proportion_blockout_hip_glute_compare", "mesh_proportion_blockout_leg_foot_compare", "mesh_proportion_blockout_girdle_compare", + "mesh_proportion_blockout_arm_hand_compare", ): assert n in names - assert len(names) == 52 + assert len(names) == 53 assert names >= TOOL_NAMES - assert len(TOOL_NAMES) == 52 + assert len(TOOL_NAMES) == 53 _run(_body()) -def test_mcp__t10_t11_join_ready_and_catalog_52() -> None: - """T10/T11: catalog 52; emit-setup/fuse-plan/open-setup; recipe join_ready; feedback tool.""" +def test_mcp__t10_t11_join_ready_and_catalog_53() -> None: + """T10/T11: catalog 53; 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) == 52 + assert len(by_name) == 53 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 @@ -146,6 +148,7 @@ async def _body() -> None: assert "mesh_proportion_blockout_hip_glute_compare" in by_name assert "mesh_proportion_blockout_leg_foot_compare" in by_name assert "mesh_proportion_blockout_girdle_compare" in by_name + assert "mesh_proportion_blockout_arm_hand_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 ec8827c..5b84fdd 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) == 52 + assert len(TOOL_NAMES) == 53 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 bae05fc..015e5a5 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) == 52 + assert len(TOOL_NAMES) == 53 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 b06557e..9102d05 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) == 52 + assert len(TOOL_NAMES) == 53 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) == 52 + assert len(names) == 53 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 7fc5167..aceaec6 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) == 52 + assert len(TOOL_NAMES) == 53 def test_t10_product_like_meters() -> None: diff --git a/tests/test_proportion_arm_hand_compare.py b/tests/test_proportion_arm_hand_compare.py new file mode 100644 index 0000000..a8277ba --- /dev/null +++ b/tests/test_proportion_arm_hand_compare.py @@ -0,0 +1,778 @@ +"""Track 0129 — blockout-arm-hand-compare JSON / honesty / scene-dump (offline). + +Authoring QA only — ARM_HAND_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.arm_hand_compare import ( + ARM_HAND_COMPARE_ROLES, + ARM_HAND_COMPARE_SCHEMA_VERSION, + SUGGESTED_ACTIONS, + build_arm_hand_metrics, + extract_recipe_arm_hand_part, + run_blockout_arm_hand_compare, +) +from meshops.proportion.blockout_recipe import RECIPE_ID, RECIPE_SCHEMA_VERSION +from meshops.proportion.errors import ProportionError +from meshops.proportion.honesty import ARM_HAND_COMPARE_HONESTY, PROPORTION_HONESTY +from meshops.proportion.models import ( + PROPORTION_SCHEMA_VERSION, + LandmarkXYZ, + ProportionReport, + QualityFlags, +) + +runner = CliRunner() + +_SUGGESTED = frozenset({"skip", "hold_priors", "soft_adjust", "session_arm_hand", "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( + *, + include_ua_r: bool = True, + bi_past_small: bool = False, + thumb_forward: bool = True, +) -> dict[str, Any]: + # Inventory-class: bicep front past ~0.010; FA dist/prox 0.70; UA r 0.04379. + ua_r = 0.04379 + ua_mid_y = 0.0 + bi_ry = 0.03074 + bi_past = 0.003 if bi_past_small else 0.010 + bi_cy = ua_mid_y - ua_r - bi_past + bi_ry + tri_ry = 0.02945 + tri_past = 0.003 if bi_past_small else 0.010 + tri_cy = ua_mid_y + ua_r + tri_past - tri_ry + + ua_p0 = [-0.2575, ua_mid_y, 1.3802] + ua_p1 = [-0.3275, ua_mid_y, 1.2593] + + thumb_p0 = [-0.45, -0.05, 0.95] + thumb_p1 = [-0.46, -0.12, 0.90] if thumb_forward else [-0.46, 0.02, 0.90] + + parts: list[dict[str, Any]] = [ + _capsule( + "RECIPE_limb_upper_arm_l", + "limb_segment", + ua_p0, + ua_p1, + ua_r, + ), + _capsule( + "RECIPE_arm_taper_dist_ua_l", + "limb_segment", + [-0.3275, 0.0, 1.2593], + [-0.3976, -0.0147, 1.1385], + 0.03678, + ), + _capsule( + "RECIPE_limb_forearm_l", + "limb_segment", + [-0.3976, -0.0293, 1.1385], + [-0.4335, -0.0440, 1.0209], + 0.03503, + ), + _capsule( + "RECIPE_arm_taper_dist_fa_l", + "limb_segment", + [-0.4335, -0.0440, 1.0209], + [-0.4695, -0.0586, 0.9034], + 0.02452, + ), + _ellipsoid( + "RECIPE_bicep_soft_l", + "bicep_soft", + [-0.3275, bi_cy, 1.2593], + 0.03415, + bi_ry, + 0.03245, + ), + _ellipsoid( + "RECIPE_triceps_soft_l", + "limb_segment", + [-0.3275, tri_cy, 1.2593], + 0.03591, + tri_ry, + 0.03411, + ), + _ellipsoid( + "RECIPE_elbow_soft_l", + "limb_segment", + [-0.3976, -0.0293, 1.1385], + 0.04487, + 0.04487, + 0.04487, + ), + _ellipsoid( + "RECIPE_dist_soft_forearm_l", + "limb_segment", + [-0.4695, -0.0586, 0.9034], + 0.03562, + 0.03562, + 0.03562, + ), + _ellipsoid( + "RECIPE_palm_l", + "palm", + [-0.4695, -0.0586, 0.9034], + 0.03750, + 0.03397, + 0.02903, + ), + _capsule( + "RECIPE_thumb_soft_0_l", + "thumb_soft", + thumb_p0, + thumb_p1, + 0.01500, + ), + _capsule( + "RECIPE_thumb_soft_1_l", + "thumb_soft", + [-0.46, -0.12, 0.90], + [-0.47, -0.16, 0.86], + 0.01200, + ), + _capsule( + "RECIPE_finger_index_0_l", + "finger_soft", + [-0.48, -0.06, 0.88], + [-0.49, -0.07, 0.84], + 0.01128, + ), + _capsule( + "RECIPE_finger_pinky_0_l", + "finger_soft", + [-0.45, -0.05, 0.88], + [-0.46, -0.06, 0.84], + 0.01000, + ), + _ellipsoid( + "RECIPE_deltoid_soft_l", + "deltoid_soft", + [-0.2622, 0.0, 1.3367], + 0.05911, + 0.03665, + 0.06384, + ), + ] + if not include_ua_r: + parts[0]["radius_m"] = None + return _recipe_doc(parts=parts) + + +def test_b1_bicep_front_past_when_recipe() -> None: + """B1: recipe present → bicep_front_past_m finite (~0.010 class).""" + metrics = build_arm_hand_metrics(_report(), recipe=_productish_recipe()) + assert metrics is not None + assert metrics.bicep_front_past_m == pytest.approx(0.010, abs=1e-4) + assert metrics.triceps_rear_past_m == pytest.approx(0.010, abs=1e-4) + assert metrics.ua_prox_r_m == pytest.approx(0.04379, abs=1e-5) + assert metrics.ua_dist_r_m == pytest.approx(0.03678, abs=1e-5) + assert metrics.fa_prox_r_m == pytest.approx(0.03503, abs=1e-5) + assert metrics.fa_dist_r_m == pytest.approx(0.02452, abs=1e-5) + assert metrics.bicep_rx_m == pytest.approx(0.03415, abs=1e-5) + assert metrics.triceps_rx_m == pytest.approx(0.03591, abs=1e-5) + assert metrics.palm_rx_m == pytest.approx(0.03750, abs=1e-5) + assert metrics.palm_ry_m == pytest.approx(0.03397, abs=1e-5) + + +def test_b2_front_only_y_null() -> None: + """B2: front-only measured → y_m null on new arm/hand ids (no invent).""" + report = _report( + { + "bi_belly_l": _lm("bi_belly_l", x_m=-0.33, z_m=1.26), + "humeral_head_l": _lm("humeral_head_l", x_m=-0.26, z_m=1.38), + } + ) + metrics = build_arm_hand_metrics(report, recipe=None) + assert metrics is not None + assert metrics.y_m.get("bi_belly_l") is None + assert metrics.y_m.get("humeral_head_l") is None + + +def test_b3_missing_one_bi_belly_no_invent() -> None: + """B3: missing one bi_belly → no invent contralateral.""" + report = _report({"bi_belly_l": _lm("bi_belly_l", x_m=-0.33, y_m=-0.04, z_m=1.26)}) + metrics = build_arm_hand_metrics(report, recipe=None) + assert metrics is not None + assert metrics.y_m.get("bi_belly_l") == pytest.approx(-0.04, abs=1e-9) + assert metrics.y_m.get("bi_belly_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 "arm_hand_metrics" not in dumped + + +def test_b5_past_none_without_ua_r() -> None: + """B5 / B35: UA r missing → bicep_front_past_m is None (never invent).""" + report = _report( + { + "upper_arm_l": _lm("upper_arm_l", x_m=-0.30, z_m=1.30), + } + ) + metrics = build_arm_hand_metrics(report, recipe=_productish_recipe(include_ua_r=False)) + assert metrics is not None + assert metrics.bicep_front_past_m is None + assert metrics.triceps_rear_past_m is None + assert metrics.ua_prox_r_m is None + + +def test_c1_recipe_bicep_and_ua_extract() -> None: + """C1: recipe JSON extracts RECIPE_bicep_soft_l axes + RECIPE_limb_upper_arm_l p0/p1.""" + bi = extract_recipe_arm_hand_part(_productish_recipe()["parts"], "bi_belly_l") + assert bi is not None + assert bi["name"] == "RECIPE_bicep_soft_l" + assert bi["kind"] == "ellipsoid" + assert bi["center"] is not None + assert bi["rx_m"] == pytest.approx(0.03415, abs=1e-6) + assert bi["ry_m"] == pytest.approx(0.03074, abs=1e-6) + ua = extract_recipe_arm_hand_part(_productish_recipe()["parts"], "humeral_head_l") + assert ua is not None + assert ua["name"] == "RECIPE_limb_upper_arm_l" + assert ua["kind"] == "capsule" + assert ua["p0"] is not None + assert ua["p1"] is not None + assert ua["radius_m"] == pytest.approx(0.04379, 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_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + assert payload["ok"] is True + for role in payload["roles"]: + assert role["live"] is None + + +def test_c2b_valid_dump_live_populated(tmp_path: Path) -> None: + """Valid --scene-dump overlays live recipe extract (not null).""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + dump = _write_recipe( + tmp_path / "dump.json", + _recipe_doc( + parts=[ + _ellipsoid( + "RECIPE_bicep_soft_l", + "bicep_soft", + [-0.33, -0.05, 1.26], + 0.034, + 0.031, + 0.032, + ) + ] + ), + ) + payload = run_blockout_arm_hand_compare( + report, + recipe, + tmp_path / "cmp", + scene_dump=dump, + force=True, + ) + bi = next(r for r in payload["roles"] if r["id"] == "bi_belly_l") + assert bi["live"] is not None + assert bi["live"]["center"] is not None + assert bi["live"]["center"][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_arm_hand_compare( + report, + recipe, + tmp_path / "cmp", + scene_dump=dump, + force=True, + ) + assert not (tmp_path / "cmp" / "arm_hand_compare.json").is_file() + + +def test_c4_capsule_extract_mapped_endpoint() -> None: + """C4 / B26: capsule UA extract uses mapped p0; rx_m stays null.""" + p0 = [-0.2575, 0.0, 1.3802] + p1 = [-0.3275, 0.0, 1.2593] + head = extract_recipe_arm_hand_part( + [_capsule("RECIPE_limb_upper_arm_l", "limb_segment", p0, p1, 0.04379)], + "humeral_head_l", + ) + assert head is not None + assert head["kind"] == "capsule" + assert head["center"] is not None + assert head["center"][1] == pytest.approx(p0[1], abs=1e-9) + assert head["p0"] == p0 + assert head["p1"] == p1 + assert head["radius_m"] == pytest.approx(0.04379, abs=1e-9) + assert head["rx_m"] is None + + +def test_c5_triceps_by_name_limb_segment() -> None: + """C5 / B34: triceps role=limb_segment found by name RECIPE_triceps_soft_l.""" + tri = extract_recipe_arm_hand_part(_productish_recipe()["parts"], "tri_belly_l") + assert tri is not None + assert tri["name"] == "RECIPE_triceps_soft_l" + assert tri["role"] == "limb_segment" + assert tri["center"] is not None + + +def test_d1_honesty_schema_region(tmp_path: Path) -> None: + """D1: payload honesty / schema 1.0.0 / region=arm_hand.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + assert payload["honesty"] == ARM_HAND_COMPARE_HONESTY + assert payload["schema_version"] == ARM_HAND_COMPARE_SCHEMA_VERSION + assert payload["schema_version"] == "1.0.0" + assert payload["region"] == "arm_hand" + raw = json.loads((tmp_path / "cmp" / "arm_hand_compare.json").read_text(encoding="utf-8")) + assert raw["honesty"] == ARM_HAND_COMPARE_HONESTY + assert raw["region"] == "arm_hand" + + +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_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + assert SUGGESTED_ACTIONS == _SUGGESTED + ids = {r["id"] for r in payload["roles"]} + for lid in ARM_HAND_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 (ellipsoid center).""" + doc = _productish_recipe() + bi = next(p for p in doc["parts"] if p["name"] == "RECIPE_bicep_soft_l") + assert bi["center"] is not None + cy = float(bi["center"][1]) + report = _write_report( + tmp_path / "report.json", + _report( + { + "bi_belly_l": _lm("bi_belly_l", x_m=-0.3275, y_m=cy + 0.02, z_m=1.2593), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", doc) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + role = next(r for r in payload["roles"] if r["id"] == "bi_belly_l") + assert role["delta_mm"] is not None + assert role["delta_mm"]["y"] == pytest.approx(20.0, abs=1e-3) + assert role["suggested"] == "soft_adjust" + + +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_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + bi = next(r for r in payload["roles"] if r["id"] == "bi_belly_l") + assert bi["measured"] is None + assert bi["suggested"] == "skip" + assert "missing_id" in bi["form_read"] + dumped = json.dumps(payload) + assert "NaN" not in dumped + assert "Infinity" not in dumped + + +def test_d5_fa_stepped_token(tmp_path: Path) -> None: + """D5: synthetic FA dist/prox ≤ 0.78 → fa_stepped in form_read.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + tokens: list[str] = [] + for role in payload["roles"]: + tokens.extend(role.get("form_read") or []) + assert "fa_stepped" 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-arm-hand-compare", "--help"]) + assert help_result.exit_code == 0 + assert "blockout-arm-hand-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-arm-hand-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"] == "arm_hand" + + +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-arm-hand-compare", + "--report", + str(report), + "--recipe", + str(recipe), + "--out", + str(tmp_path / "cmp"), + "--force", + ], + ) + assert result.exit_code == 0 + assert ARM_HAND_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_bi_front_past_and_thumb_not_forward(tmp_path: Path) -> None: + """D8: synthetic bicep past <0.006 -> bi_front_past; thumb axis Y >= -0.40.""" + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe( + tmp_path / "recipe.json", + _productish_recipe(bi_past_small=True, thumb_forward=False), + ) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + tokens: list[str] = [] + for role in payload["roles"]: + tokens.extend(role.get("form_read") or []) + assert "bi_front_past" in tokens + assert "tri_rear_past" in tokens + assert "thumb_not_forward" in tokens + + +def test_d9_compare_only_ids_hold_priors(tmp_path: Path) -> None: + """D9 / B23: humeral/olecranon/fa/palm/thumb/mcp/HAVE stay hold_priors.""" + report = _write_report( + tmp_path / "report.json", + _report( + { + "humeral_head_l": _lm("humeral_head_l", x_m=-0.2575, y_m=0.02, z_m=1.3802), + "olecranon_l": _lm("olecranon_l", x_m=-0.3976, y_m=-0.01, z_m=1.1385), + "fa_belly_l": _lm("fa_belly_l", x_m=-0.415, y_m=-0.02, z_m=1.08), + "palm_center_l": _lm("palm_center_l", x_m=-0.4695, y_m=-0.04, z_m=0.9034), + "thumb_cmc_l": _lm("thumb_cmc_l", x_m=-0.45, y_m=-0.03, z_m=0.95), + "mcp_index_l": _lm("mcp_index_l", x_m=-0.48, y_m=-0.04, z_m=0.88), + "shoulder_l": _lm("shoulder_l", x_m=-0.2622, y_m=0.01, z_m=1.3367), + "elbow_l": _lm("elbow_l", x_m=-0.3976, y_m=-0.01, z_m=1.1385), + "wrist_l": _lm("wrist_l", x_m=-0.4695, y_m=-0.04, z_m=0.9034), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + for lid in ( + "humeral_head_l", + "olecranon_l", + "fa_belly_l", + "palm_center_l", + "thumb_cmc_l", + "mcp_index_l", + "shoulder_l", + "elbow_l", + "wrist_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_bi_belly_ignore_delta_x(tmp_path: Path) -> None: + """D10 / B36: bi_belly_l large delta.x and dy=dz=0 → hold_priors (ignore X).""" + doc = _productish_recipe() + bi = next(p for p in doc["parts"] if p["name"] == "RECIPE_bicep_soft_l") + cy = float(bi["center"][1]) + cz = float(bi["center"][2]) + report = _write_report( + tmp_path / "report.json", + _report( + { + "bi_belly_l": _lm("bi_belly_l", x_m=-0.60, y_m=cy, z_m=cz), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", doc) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + role = next(r for r in payload["roles"] if r["id"] == "bi_belly_l") + assert role["delta_mm"] is not None + assert abs(role["delta_mm"]["x"]) >= 1.0 + assert role["delta_mm"]["y"] == pytest.approx(0.0, abs=1e-6) + assert role["delta_mm"]["z"] == pytest.approx(0.0, abs=1e-6) + assert role["suggested"] == "hold_priors" + + +def test_d11_capsule_p0_humeral_hold_priors(tmp_path: Path) -> None: + """D11 / B39: capsule center=null + measured humeral_head_l uses p0; hold_priors.""" + report = _write_report( + tmp_path / "report.json", + _report( + { + "humeral_head_l": _lm("humeral_head_l", x_m=-0.2575, y_m=0.02, z_m=1.3802), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + head = next(r for r in payload["roles"] if r["id"] == "humeral_head_l") + assert head["recipe"] is not None + assert head["recipe"]["kind"] == "capsule" + assert head["delta_mm"] is not None + # p0 Y = 0.0; always-p0 or mid both 0 here — Δy = 20 mm from measured 0.02. + assert head["delta_mm"]["y"] == pytest.approx(20.0, abs=1e-3) + assert head["suggested"] == "hold_priors" + + +def test_d12_capsule_fa_belly_midpoint(tmp_path: Path) -> None: + """D12 / B39: FA capsule + measured fa_belly_l uses midpoint (not p0); hold_priors.""" + p0 = [-0.3976, -0.0293, 1.1385] + p1 = [-0.4335, -0.0440, 1.0209] + mid_y = (p0[1] + p1[1]) / 2.0 + report = _write_report( + tmp_path / "report.json", + _report( + { + "fa_belly_l": _lm("fa_belly_l", x_m=-0.41555, y_m=mid_y + 0.02, z_m=1.0797), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + fa = next(r for r in payload["roles"] if r["id"] == "fa_belly_l") + assert fa["recipe"] is not None + assert fa["recipe"]["kind"] == "capsule" + assert fa["delta_mm"] is not None + # Midpoint Y; wrongly using p0 (-0.0293) would yield a different delta. + assert fa["delta_mm"]["y"] == pytest.approx(20.0, abs=1e-3) + # Confirm not p0: measured_y - p0_y would be mid_y+0.02 - (-0.0293) ≠ 0.02. + assert fa["delta_mm"]["y"] != pytest.approx(((mid_y + 0.02) - p0[1]) * 1000.0, abs=1e-3) + assert fa["suggested"] == "hold_priors" + + +def test_d13_thumb_tip_p1(tmp_path: Path) -> None: + """D13 / B39: thumb_soft_1 capsule + measured thumb_tip_l uses p1 (not p0).""" + tip_p1 = [-0.47, -0.16, 0.86] + report = _write_report( + tmp_path / "report.json", + _report( + { + "thumb_tip_l": _lm("thumb_tip_l", x_m=-0.47, y_m=-0.14, z_m=0.86), + } + ), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = run_blockout_arm_hand_compare(report, recipe, tmp_path / "cmp", force=True) + tip = next(r for r in payload["roles"] if r["id"] == "thumb_tip_l") + assert tip["recipe"] is not None + assert tip["recipe"]["kind"] == "capsule" + assert tip["delta_mm"] is not None + # p1 Y = -0.16; wrongly using CMC/p0 (-0.12 on soft_1 p0) would differ. + assert tip["delta_mm"]["y"] == pytest.approx((-0.14 - tip_p1[1]) * 1000.0, abs=1e-3) + assert tip["suggested"] == "hold_priors" + + +def test_f1_mcp_catalog_53() -> None: + """F1: TOOL_NAMES 53 and arm-hand-compare tool present.""" + assert "mesh_proportion_blockout_arm_hand_compare" in TOOL_NAMES + assert len(TOOL_NAMES) == 53 + + +def test_f2_cli_contains_verb() -> None: + """F2: src/meshops/cli.py contains blockout-arm-hand-compare.""" + cli = Path("src/meshops/cli.py").read_text(encoding="utf-8") + assert "blockout-arm-hand-compare" in cli + + +def test_sidecar_unlinked_when_no_finite_ids(tmp_path: Path) -> None: + """--force with no finite arm/hand ids must not leave a stale sidecar.""" + report_hit = _write_report( + tmp_path / "hit.json", + _report({"bi_belly_l": _lm("bi_belly_l", x_m=-0.33, z_m=1.26)}), + ) + report_miss = _write_report(tmp_path / "miss.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + out = tmp_path / "cmp" + run_blockout_arm_hand_compare(report_hit, recipe, out, force=True) + assert (out / "arm_hand_metrics.json").is_file() + run_blockout_arm_hand_compare(report_miss, recipe, out, force=True) + assert not (out / "arm_hand_metrics.json").is_file() + + +def test_sidecar_arm_hand_metrics_when_ids_present(tmp_path: Path) -> None: + """Compare writes arm_hand_metrics.json when at least one v1 id is finite.""" + report = _write_report( + tmp_path / "report.json", + _report({"bi_belly_l": _lm("bi_belly_l", x_m=-0.33, z_m=1.26)}), + ) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + out = tmp_path / "cmp" + payload = run_blockout_arm_hand_compare(report, recipe, out, force=True) + sidecar = out / "arm_hand_metrics.json" + assert sidecar.is_file() + metrics = json.loads(sidecar.read_text(encoding="utf-8")) + assert metrics["bicep_front_past_m"] == pytest.approx(0.010, abs=1e-4) + assert payload["arm_hand_metrics"]["bicep_front_past_m"] == pytest.approx(0.010, 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_arm_hand_compare + + report = _write_report(tmp_path / "report.json", _report()) + recipe = _write_recipe(tmp_path / "recipe.json", _productish_recipe()) + payload = mesh_proportion_blockout_arm_hand_compare( + tmp_path, + report=str(report), + recipe=str(recipe), + out=str(tmp_path / "cmp"), + force=True, + ) + assert payload["ok"] is True + assert payload["region"] == "arm_hand" + assert (tmp_path / "cmp" / "arm_hand_compare.json").is_file() + + +def test_f4_honesty_token() -> None: + """F4: ARM_HAND_COMPARE_HONESTY in honesty.py.""" + from meshops.proportion import honesty as honesty_mod + + assert ARM_HAND_COMPARE_HONESTY == "proportion_arm_hand_compare_not_mesh_or_print_success" + assert hasattr(honesty_mod, "ARM_HAND_COMPARE_HONESTY") diff --git a/tests/test_proportion_arm_hand_landmarks.py b/tests/test_proportion_arm_hand_landmarks.py new file mode 100644 index 0000000..f27de7b --- /dev/null +++ b/tests/test_proportion_arm_hand_landmarks.py @@ -0,0 +1,215 @@ +"""Track 0129 — arm/hand landmark ids / template blanks / fuse XYZ (offline). + +Authoring measurement only (ARM_HAND_COMPARE_HONESTY / CAPTURE_HONESTY). +Not mesh or print success. Schema report stay 1.2.0. MCP 53 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, +) + +_ARM_HAND_FRONT_IDS: tuple[str, ...] = ( + "humeral_head_l", + "humeral_head_r", + "bi_belly_l", + "bi_belly_r", + "tri_belly_l", + "tri_belly_r", + "olecranon_l", + "olecranon_r", + "fa_belly_l", + "fa_belly_r", + "palm_center_l", + "palm_center_r", + "thumb_cmc_l", + "thumb_cmc_r", + "thumb_tip_l", + "thumb_tip_r", + "mcp_index_l", + "mcp_index_r", + "mcp_pinky_l", + "mcp_pinky_r", +) + +_ARM_HAND_LEFT_IDS: tuple[str, ...] = ( + "humeral_head_l", + "bi_belly_l", + "tri_belly_l", + "olecranon_l", + "fa_belly_l", + "palm_center_l", + "thumb_cmc_l", + "thumb_tip_l", +) + +_ARM_HAND_BACK_IDS: tuple[str, ...] = ( + "tri_belly_l", + "tri_belly_r", + "olecranon_l", + "olecranon_r", +) + +_ARM_HAND_NEW_IDS: tuple[str, ...] = _ARM_HAND_FRONT_IDS + + +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_arm_hand_ids_in_known() -> None: + """A1: frozen v1 form-read ids are known (membership in, not ==).""" + for lid in _ARM_HAND_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 tri/olecranon.""" + for lid in _ARM_HAND_FRONT_IDS: + assert lid in _FRONT_LANDMARK_KEYS + for lid in _ARM_HAND_LEFT_IDS: + assert lid in _LEFT_LANDMARK_KEYS + for lid in _ARM_HAND_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), + "bi_belly_l": _lm2("bi_belly_l", 20.0, 70.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front}, height_m=1.72) + assert "bi_belly_l" in out + assert out["bi_belly_l"].x_m is not None + assert out["bi_belly_l"].z_m is not None + assert out["bi_belly_l"].y is None + assert out["bi_belly_l"].y_m is None + assert "tri_belly_l" not in out + assert "humeral_head_l" not in out + + +def test_a4_arm_hand_have_ids_hold() -> None: + """A4: 0013/0024 shoulder_l / elbow_l / wrist_l stay known.""" + assert "shoulder_l" in KNOWN_LANDMARK_IDS + assert "elbow_l" in KNOWN_LANDMARK_IDS + assert "wrist_l" in KNOWN_LANDMARK_IDS + assert "shoulder_l" in _FRONT_LANDMARK_KEYS + assert "elbow_l" in _FRONT_LANDMARK_KEYS + assert "wrist_l" in _FRONT_LANDMARK_KEYS + + +def test_a5_no_arm_front_depth_pair() -> None: + """A5: do not add arm_front/back DEPTH_PAIRS.""" + flat = {lid for triple in DEPTH_PAIRS for lid in triple} + assert "arm_front" not in flat + assert "arm_back" not in flat + assert "arm_front" not in KNOWN_LANDMARK_IDS + + +def test_a6_siblings_still_known() -> None: + """A6: 0128 trap_apex_l, 0127 gastroc_med_l, 0126 asis_l, 0125 sternum_mid, 0124 eye_l.""" + assert "trap_apex_l" in KNOWN_LANDMARK_IDS + assert "gastroc_med_l" in KNOWN_LANDMARK_IDS + 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_tri_olecranon_xz() -> None: + """Back view fills X/Z for tri/olecranon; 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), + "tri_belly_l": _lm2("tri_belly_l", 70.0, 75.0), + "olecranon_l": _lm2("olecranon_l", 75.0, 95.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front, "back": back}, height_m=1.72) + assert "tri_belly_l" in out + assert out["tri_belly_l"].x is not None + assert out["tri_belly_l"].z is not None + assert out["tri_belly_l"].x_m is not None + assert out["tri_belly_l"].z_m is not None + assert out["tri_belly_l"].y is None + assert out["tri_belly_l"].y_m is None + assert out["tri_belly_l"].x < 0.0 + assert "olecranon_l" in out + assert out["olecranon_l"].z_m is not None + assert out["olecranon_l"].y_m is None + + +def test_a_left_view_sets_arm_hand_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), + "bi_belly_l": _lm2("bi_belly_l", 20.0, 70.0), + }, + ) + left = ViewLandmarks( + view="left", + width_px=100, + height_px=200, + facing_direction="camera_left", + landmarks={ + "chest_front": _lm2("chest_front", 70.0, 60.0), + "chest_back": _lm2("chest_back", 30.0, 60.0), + "bi_belly_l": _lm2("bi_belly_l", 25.0, 70.0), + "tri_belly_l": _lm2("tri_belly_l", 55.0, 72.0), + "palm_center_l": _lm2("palm_center_l", 20.0, 120.0), + }, + ) + out, _quality, _msgs = fuse_xyz({"front": front, "left": left}, height_m=1.72) + assert out["bi_belly_l"].y is not None + assert out["bi_belly_l"].y_m is not None + assert out["tri_belly_l"].y is not None + assert out["tri_belly_l"].y_m is not None + assert out["palm_center_l"].y is not None + assert out["palm_center_l"].y_m is not None diff --git a/tests/test_proportion_arm_hand_soft_consume.py b/tests/test_proportion_arm_hand_soft_consume.py new file mode 100644 index 0000000..d16d5e7 --- /dev/null +++ b/tests/test_proportion_arm_hand_soft_consume.py @@ -0,0 +1,182 @@ +"""Track 0129 — soft new-id bi/tri Y/Z (no 0062/0063/0103/0081/0088/0104 retune). + +Does not weaken 0062 T* / 0063 bi-tri / 0103 delt / 0081 elbow / 0088 finger / 0104 curl. +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 ( + BICEP_ALONG_T, + BICEP_FRONT_PAST_M, + DELT_DISTAL_BURY_T, + DELT_RY_FRAC, + DELT_RZ_FRAC, + ELBOW_SOFT_SCALE, + FA_DIST_SHAFT_SCALE, + UA_DIST_SHAFT_SCALE, + build_blockout_recipe, +) +from meshops.proportion.extremity_recipe import _FINGER_CURL_PIP_DEG, _THUMB_PALM_PITCH +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_bicep_y_after_muscle_call() -> None: + """E1: measured bi_belly_l Y → bicep_soft_l.center[1] tracks after L4778. + + B32/B33: write as next statement after muscle call. Negative Y catches abs(). + """ + report = _product_class_report() + measured_y = -0.04 + report.landmarks_xyz["bi_belly_l"] = _lm("bi_belly_l", x_m=-0.33, y_m=measured_y, z_m=1.26) + pkg = _emit(report) + bicep = next(p for p in pkg.parts if p.name == "RECIPE_bicep_soft_l") + assert bicep.center is not None + assert float(bicep.center[1]) == pytest.approx(measured_y, abs=1e-6) + assert float(bicep.center[1]) < 0.0 + assert any("arm_hand: measured bicep y=" in m for m in pkg.messages) + + +def test_e2_absent_ids_prior_path() -> None: + """E2: absent bi/tri ids → bicep/triceps Y/Z match 0063 prior path.""" + report = _product_class_report() + pkg = _emit(report) + bicep = next(p for p in pkg.parts if p.name == "RECIPE_bicep_soft_l") + assert bicep.center is not None + prior_y = float(bicep.center[1]) + prior_z = float(bicep.center[2]) + assert prior_y != pytest.approx(-0.04, abs=1e-3) + assert prior_z != pytest.approx(1.20, abs=1e-3) + assert not any("arm_hand: measured bicep y=" in m for m in pkg.messages) + assert not any("arm_hand: measured triceps z=" in m for m in pkg.messages) + + +def test_e3_const_hold() -> None: + """E3: 0062/0063/0103/0081/0088/0104 hold — B13 exact-equality.""" + assert UA_DIST_SHAFT_SCALE == 0.84 + assert FA_DIST_SHAFT_SCALE == 0.70 + assert BICEP_FRONT_PAST_M == 0.010 + assert BICEP_ALONG_T == 0.50 + assert DELT_RY_FRAC == 0.62 + assert DELT_RZ_FRAC == 1.08 + assert DELT_DISTAL_BURY_T == 0.36 + assert ELBOW_SOFT_SCALE == 1.22 + assert _FINGER_CURL_PIP_DEG == 14.0 + assert _THUMB_PALM_PITCH == -0.55 + + +def test_e4_front_only_humeral_no_ua_p0_y() -> None: + """E4: front-only measured humeral_head → no Y write on UA p0.""" + report = _product_class_report() + baseline = _emit(report) + report.landmarks_xyz["humeral_head_l"] = _lm("humeral_head_l", x_m=-0.26, z_m=1.38) + moved = _emit(report) + base_ua = next(p for p in baseline.parts if p.name == "RECIPE_limb_upper_arm_l") + new_ua = next(p for p in moved.parts if p.name == "RECIPE_limb_upper_arm_l") + assert base_ua.p0 is not None and new_ua.p0 is not None + assert float(new_ua.p0[1]) == pytest.approx(float(base_ua.p0[1]), abs=1e-9) + + +def test_e5_have_ids_do_not_change_bicep() -> None: + """E5: elbow_l/wrist_l/shoulder_l finite must not fire 0129 bi/tri overlay. + + Product-class reports already carry HAVE arm joints. Re-emitting without + bi/tri belly ids must keep BICEP_*/UA_DIST_* and must not write arm_hand + overlay messages (do not retarget elbow/wrist X/Z — those drive hang). + """ + report = _product_class_report() + assert "shoulder_l" in report.landmarks_xyz + pkg = _emit(report) + assert BICEP_FRONT_PAST_M == 0.010 + assert UA_DIST_SHAFT_SCALE == 0.84 + bicep = next(p for p in pkg.parts if p.name == "RECIPE_bicep_soft_l") + assert bicep.center is not None + # HAVE ids present on product report must not imply soft consume fired. + assert not any("arm_hand: measured bicep" in m for m in pkg.messages) + assert not any("arm_hand: measured triceps" in m for m in pkg.messages) + + +def test_e6_measured_fa_belly_y_does_not_change_fa_r() -> None: + """E6: measured fa_belly_l Y finite must not change FA radius_m vs 0062.""" + report = _product_class_report() + baseline = _emit(report) + report.landmarks_xyz["fa_belly_l"] = _lm("fa_belly_l", x_m=-0.42, y_m=-0.04, z_m=1.08) + moved = _emit(report) + base_fa = next(p for p in baseline.parts if p.name == "RECIPE_limb_forearm_l") + new_fa = next(p for p in moved.parts if p.name == "RECIPE_limb_forearm_l") + assert float(new_fa.radius_m or 0.0) == pytest.approx(float(base_fa.radius_m or 0.0), abs=1e-9) + + +def test_e7_one_side_measured_y_after_muscle() -> 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_bicep_soft_r") + assert base_r.center is not None + prior_r = float(base_r.center[1]) + measured_y = 0.08 + assert measured_y != pytest.approx(prior_r, abs=1e-3) + report.landmarks_xyz["bi_belly_l"] = _lm("bi_belly_l", x_m=-0.33, y_m=measured_y, z_m=1.26) + pkg = _emit(report) + bi_l = next(p for p in pkg.parts if p.name == "RECIPE_bicep_soft_l") + bi_r = next(p for p in pkg.parts if p.name == "RECIPE_bicep_soft_r") + assert bi_l.center is not None and bi_r.center is not None + assert float(bi_l.center[1]) == pytest.approx(measured_y, abs=1e-6) + assert float(bi_r.center[1]) == pytest.approx(prior_r, abs=1e-6) + + +def test_e8_measured_triceps_z_after_muscle() -> None: + """E8: measured tri_belly_l Z → triceps_soft_l.center[2] tracks (B34 name lookup).""" + report = _product_class_report() + measured_z = 1.22 + report.landmarks_xyz["tri_belly_l"] = _lm("tri_belly_l", x_m=-0.33, y_m=0.01, z_m=measured_z) + pkg = _emit(report) + tri = next(p for p in pkg.parts if p.name == "RECIPE_triceps_soft_l") + assert tri.center is not None + assert float(tri.center[2]) == pytest.approx(measured_z, abs=1e-6) + assert any("arm_hand: measured triceps z=" in m for m in pkg.messages) + assert tri.role == "limb_segment" + + +def test_e9_no_shoulder_ball_emit() -> None: + """E9: emit contains no part named RECIPE_shoulder_ball_l.""" + report = _product_class_report() + report.landmarks_xyz["humeral_head_l"] = _lm("humeral_head_l", x_m=-0.26, y_m=0.0, z_m=1.38) + pkg = _emit(report) + names = {p.name for p in pkg.parts} + assert "RECIPE_shoulder_ball_l" not in names + assert "RECIPE_shoulder_ball_r" not in names + + +def test_e_mcp_catalog_53() -> None: + assert len(TOOL_NAMES) == 53 + assert "mesh_proportion_blockout_arm_hand_compare" in TOOL_NAMES diff --git a/tests/test_proportion_breast_chest_contact.py b/tests/test_proportion_breast_chest_contact.py index e1a10ab..a043098 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) == 52 + assert len(TOOL_NAMES) == 53 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 32c54da..91bfe04 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) == 52 + assert len(TOOL_NAMES) == 53 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 4edefe5..0e519c4 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) == 52 + assert len(TOOL_NAMES) == 53 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 7cbecfa..abec01d 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) == 52 + assert len(TOOL_NAMES) == 53 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 7ab8777..bbb3c04 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) == 52 + assert len(TOOL_NAMES) == 53 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 8ccf220..f585316 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) == 52 + assert len(TOOL_NAMES) == 53 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 0755796..58a5a76 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) == 52 + assert len(TOOL_NAMES) == 53 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 b7a8bb2..333e842 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) == 52 + assert len(TOOL_NAMES) == 53 assert RECIPE_SCHEMA_VERSION == "1.4.0" diff --git a/tests/test_proportion_extremity_recipe.py b/tests/test_proportion_extremity_recipe.py index 7d8701f..64f59be 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) == 52 + assert len(TOOL_NAMES) == 53 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) == 52 + assert len(names) == 53 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) == 52 + assert len(TOOL_NAMES) == 53 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 31216d4..541788d 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) == 52 + assert len(TOOL_NAMES) == 53 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 98bf38f..e4d6b71 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) == 52 + assert len(TOOL_NAMES) == 53 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 f294d79..7f01cfe 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) == 52 + assert len(TOOL_NAMES) == 53 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) == 52 + assert len(names) == 53 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 5d805a4..f020d65 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) == 52 + assert len(TOOL_NAMES) == 53 diff --git a/tests/test_proportion_foot_scale_plus.py b/tests/test_proportion_foot_scale_plus.py index fced97b..50aeb8b 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) == 52 + assert len(TOOL_NAMES) == 53 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 6867939..870fe94 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) == 52 + assert len(TOOL_NAMES) == 53 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 224595a..ad32266 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) == 52 + assert len(TOOL_NAMES) == 53 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 5abedc7..8f292cc 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) == 52 + assert len(TOOL_NAMES) == 53 assert RECIPE_SCHEMA_VERSION == "1.4.0" diff --git a/tests/test_proportion_girdle_compare.py b/tests/test_proportion_girdle_compare.py index cb463cd..8496ab9 100644 --- a/tests/test_proportion_girdle_compare.py +++ b/tests/test_proportion_girdle_compare.py @@ -702,10 +702,10 @@ def test_d13_scm_and_nape_mapped_endpoints(tmp_path: Path) -> None: assert nape["suggested"] == "hold_priors" -def test_f1_mcp_catalog_52() -> None: - """F1: TOOL_NAMES 52 and girdle-compare tool present.""" +def test_f1_mcp_catalog_53() -> None: + """F1: TOOL_NAMES 53 and girdle-compare tool present.""" assert "mesh_proportion_blockout_girdle_compare" in TOOL_NAMES - assert len(TOOL_NAMES) == 52 + assert len(TOOL_NAMES) == 53 def test_f2_cli_contains_verb() -> None: diff --git a/tests/test_proportion_girdle_soft_consume.py b/tests/test_proportion_girdle_soft_consume.py index cbe763d..65c3d3b 100644 --- a/tests/test_proportion_girdle_soft_consume.py +++ b/tests/test_proportion_girdle_soft_consume.py @@ -192,6 +192,6 @@ def test_e8_measured_trap_z_after_nape() -> None: assert any("measured trap z=" in m for m in pkg.messages) -def test_e_mcp_catalog_52() -> None: - assert len(TOOL_NAMES) == 52 +def test_e_mcp_catalog_53() -> None: + assert len(TOOL_NAMES) == 53 assert "mesh_proportion_blockout_girdle_compare" in TOOL_NAMES diff --git a/tests/test_proportion_hand_digit_curl.py b/tests/test_proportion_hand_digit_curl.py index 9887508..316536e 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) == 52 + assert len(TOOL_NAMES) == 53 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 e2937f2..53612c3 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) == 52 + assert len(TOOL_NAMES) == 53 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 99196ef..9e146b0 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) == 52 + assert len(TOOL_NAMES) == 53 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 e98ddad..729fb0b 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) == 52 + assert len(TOOL_NAMES) == 53 diff --git a/tests/test_proportion_hip_glute_compare.py b/tests/test_proportion_hip_glute_compare.py index 30ad69b..018af53 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) == 52 + assert len(TOOL_NAMES) == 53 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 deb3cfa..acd0668 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) == 52 + assert len(TOOL_NAMES) == 53 diff --git a/tests/test_proportion_hip_hierarchy.py b/tests/test_proportion_hip_hierarchy.py index ce80ad6..14c798d 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) == 52 + assert len(TOOL_NAMES) == 53 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 7ec7534..dd3da87 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) == 52 + assert len(TOOL_NAMES) == 53 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 6777656..ce7bc79 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) == 52 + assert len(TOOL_NAMES) == 53 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 index 8a4be4c..4e38208 100644 --- a/tests/test_proportion_leg_foot_compare.py +++ b/tests/test_proportion_leg_foot_compare.py @@ -716,7 +716,7 @@ def test_d11_capsule_p0_soft_adjust(tmp_path: Path) -> None: 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) == 52 + assert len(TOOL_NAMES) == 53 def test_f2_cli_contains_verb() -> None: diff --git a/tests/test_proportion_leg_foot_soft_consume.py b/tests/test_proportion_leg_foot_soft_consume.py index a25fcfb..570d215 100644 --- a/tests/test_proportion_leg_foot_soft_consume.py +++ b/tests/test_proportion_leg_foot_soft_consume.py @@ -190,5 +190,5 @@ def test_e8_measured_arch_z_after_append() -> None: def test_e_mcp_catalog_51() -> None: - assert len(TOOL_NAMES) == 52 + assert len(TOOL_NAMES) == 53 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 89e939c..ea4ce51 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) == 52 + assert len(TOOL_NAMES) == 53 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 9bda674..c82cdf4 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) == 52 + assert len(TOOL_NAMES) == 53 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 df56119..390bc80 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) == 52 + assert len(TOOL_NAMES) == 53 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 42f77ef..12a3739 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) == 52" in mcp_test + assert "len(TOOL_NAMES) == 53" 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) == 52 + assert len(TOOL_NAMES) == 53 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) == 52 + assert len(TOOL_NAMES) == 53 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 d7a59b2..e423984 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) == 52 + assert len(TOOL_NAMES) == 53 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 4e31b02..40e4fab 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) == 52 + assert len(TOOL_NAMES) == 53 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 9d6f68b..05d492b 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) == 52 + assert len(TOOL_NAMES) == 53 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 4427ada..11d8089 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) == 52 + assert len(TOOL_NAMES) == 53 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 44e685f..2ec456b 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) == 52 + assert len(TOOL_NAMES) == 53 def test_f2_cli_contains_verb() -> None: diff --git a/tests/test_proportion_torso_continuous.py b/tests/test_proportion_torso_continuous.py index b9a2a13..9404e38 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) == 52 + assert len(TOOL_NAMES) == 53 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 709f0b5..dc84cf8 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) == 52 + assert len(TOOL_NAMES) == 53 diff --git a/tests/test_proportion_torso_thoracic_front.py b/tests/test_proportion_torso_thoracic_front.py index 9dbc2ce..3c9b526 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) == 52 + assert len(TOOL_NAMES) == 53 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 e9e7bad..f6c1988 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) == 52 + assert len(TOOL_NAMES) == 53 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 ee26140..d8cb00b 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) == 52 + assert len(TOOL_NAMES) == 53 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 b55d5c8..18a6bc7 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) == 52 + assert len(TOOL_NAMES) == 53 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 5431013..31dc22a 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) == 52 + assert len(TOOL_NAMES) == 53 def test_t7_no_hide_cli_command() -> None: