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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion src/meshops/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@
"blockout-torso-compare photo vs RECIPE vs optional scene "
"(0125; TORSO_COMPARE_HONESTY — N6); "
"blockout-hip-glute-compare photo vs RECIPE vs optional scene "
"(0126; HIP_GLUTE_COMPARE_HONESTY — N6). "
"(0126; HIP_GLUTE_COMPARE_HONESTY — N6); "
"blockout-leg-foot-compare photo vs RECIPE vs optional scene "
"(0127; LEG_FOOT_COMPARE_HONESTY — N6). "
"Optional: meshops[proportion] (Pillow)."
),
add_completion=False,
Expand Down Expand Up @@ -3486,5 +3488,71 @@ def proportion_blockout_hip_glute_compare_cmd(
raise typer.Exit(0)


@proportion_app.command("blockout-leg-foot-compare")
def proportion_blockout_leg_foot_compare_cmd(
report: Path = typer.Option(
...,
"--report",
help="Path to proportion_report.json (required)",
),
recipe: Path = typer.Option(
...,
"--recipe",
help="Path to blockout_recipe.json (required)",
),
out: Path = typer.Option(
...,
"--out",
help="Output directory for leg_foot_compare.json (required)",
),
scene_dump: Path | None = typer.Option(
None,
"--scene-dump",
help="Optional live Blender/scene dump JSON overlay",
),
force: bool = typer.Option(
False,
"--force",
help="Overwrite existing leg_foot_compare.json",
),
json_out: bool = typer.Option(False, "--json", help="Emit machine result JSON"),
) -> None:
"""Compare Package A leg/foot landmarks vs RECIPE vs optional live scene.

Authoring QA only — not mesh or print success (LEG_FOOT_COMPARE_HONESTY).
"""
from meshops.proportion.errors import ProportionError
from meshops.proportion.honesty import LEG_FOOT_COMPARE_HONESTY
from meshops.proportion.leg_foot_compare import run_blockout_leg_foot_compare

try:
payload = run_blockout_leg_foot_compare(
report,
recipe,
out,
scene_dump=scene_dump,
force=force,
)
except ProportionError as exc:
_emit_error(exc, json_mode=json_out, code=1)
except Exception as exc:
_emit_error(exc, json_mode=json_out)

if json_out:
_emit_json(payload)
else:
typer.echo(
f"blockout-leg-foot-compare ok={payload.get('ok')} region={payload.get('region')}"
)
for msg in payload.get("messages") or []:
typer.echo(f" note: {msg}")
pkg_path = payload.get("package_path")
if pkg_path:
typer.echo(f" {pkg_path}")
typer.echo(f"honesty: {LEG_FOOT_COMPARE_HONESTY}")
typer.echo("blockout-leg-foot-compare authoring QA only — not mesh or print success")
raise typer.Exit(0)


if __name__ == "__main__":
app()
23 changes: 23 additions & 0 deletions src/meshops/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
"mesh_proportion_blockout_face_compare",
"mesh_proportion_blockout_torso_compare",
"mesh_proportion_blockout_hip_glute_compare",
"mesh_proportion_blockout_leg_foot_compare",
}
)

Expand Down Expand Up @@ -1021,4 +1022,26 @@ def mesh_proportion_blockout_hip_glute_compare(
force=force,
)

@mcp.tool()
def mesh_proportion_blockout_leg_foot_compare(
report: str,
recipe: str,
out: str,
scene_dump: str | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Compare Package A leg/foot landmarks vs RECIPE vs optional live scene dump.

Authoring QA only — proportion_leg_foot_compare_not_mesh_or_print_success.
Not mesh or print success. Raises ProportionError on hard failures.
"""
return T.mesh_proportion_blockout_leg_foot_compare(
wr,
report=report,
recipe=recipe,
out=out,
scene_dump=scene_dump,
force=force,
)

return mcp
27 changes: 27 additions & 0 deletions src/meshops/mcp/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -1430,3 +1430,30 @@ def mesh_proportion_blockout_hip_glute_compare(
scene_dump=_resolve_tool_path(scene_dump, work_root) if scene_dump else None,
force=force,
)


def mesh_proportion_blockout_leg_foot_compare(
work_root: Path,
*,
report: str,
recipe: str,
out: str,
scene_dump: str | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Photo vs RECIPE vs optional scene. Authoring only — LEG_FOOT_COMPARE_HONESTY."""
from meshops.proportion.leg_foot_compare import run_blockout_leg_foot_compare

ends_sep = out.endswith(("/", "\\"))
out_base = out.rstrip("/\\") if ends_sep else out
out_resolved = _resolve_tool_path(out_base, work_root)
out_arg: str | Path = (
str(out_resolved) + ("\\" if ends_sep else "") if ends_sep else out_resolved
)
return run_blockout_leg_foot_compare(
_resolve_tool_path(report, work_root),
_resolve_tool_path(recipe, work_root),
out_arg,
scene_dump=_resolve_tool_path(scene_dump, work_root) if scene_dump else None,
force=force,
)
43 changes: 43 additions & 0 deletions src/meshops/proportion/assist.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,21 @@
"groin_fold_r",
"thigh_medial_l",
"thigh_medial_r",
# 0127 leg/ankle/foot form-read (not Pose 25-32 / boots)
"gastroc_med_l",
"gastroc_med_r",
"gastroc_lat_l",
"gastroc_lat_r",
"malleolus_med_l",
"malleolus_med_r",
"malleolus_lat_l",
"malleolus_lat_r",
"arch_apex_l",
"arch_apex_r",
"achilles_l",
"achilles_r",
"ball_l",
"ball_r",
}
)

Expand Down Expand Up @@ -216,6 +231,34 @@
"glute_top_seam",
)

# Frozen v1 leg/ankle/foot form-read ids (0127). Existing knee/ankle/calf/heel/toe/foot stay.
LEG_FOOT_FRONT_LANDMARK_IDS: tuple[str, ...] = (
"malleolus_med_l",
"malleolus_med_r",
"malleolus_lat_l",
"malleolus_lat_r",
"gastroc_med_l",
"gastroc_med_r",
"gastroc_lat_l",
"gastroc_lat_r",
"ball_l",
"ball_r",
)
LEG_FOOT_LEFT_LANDMARK_IDS: tuple[str, ...] = (
"gastroc_med_l",
"arch_apex_l",
"achilles_l",
"malleolus_med_l",
)
LEG_FOOT_BACK_LANDMARK_IDS: tuple[str, ...] = (
"gastroc_med_l",
"gastroc_med_r",
"gastroc_lat_l",
"gastroc_lat_r",
"achilles_l",
"achilles_r",
)


def _clamp01(v: float) -> float:
return max(0.0, min(1.0, float(v)))
Expand Down
34 changes: 34 additions & 0 deletions src/meshops/proportion/blockout_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -3216,6 +3216,37 @@ def _sync_calf_distal_to_ankle(
messages.append(f"calf_{side}: distal/cyl p1 Y synced to ank_foot ({ay:.4f})")


def _apply_measured_calf_cyl_y(
parts: list[RecipePart],
report: ProportionReport,
messages: list[str],
) -> None:
"""0127: overlay measured gastroc_med_* Y onto calf_cyl p0 after B6, then mid."""
by_name = {p.name: p for p in parts}
for side in ("l", "r"):
my = _measured_lm_m(report, f"gastroc_med_{side}", y=True)
if my is None:
continue
cyl = by_name.get(f"RECIPE_calf_cyl_{side}")
if cyl is None or cyl.p0 is None or len(cyl.p0) < 3:
continue
cyl.p0 = [float(cyl.p0[0]), my, float(cyl.p0[2])]
cyl.placement = "full3d"
taper = by_name.get(f"RECIPE_calf_taper_dist_{side}")
dest: list[float] | None = None
if taper is not None and taper.p1 is not None and len(taper.p1) >= 3:
dest = [float(taper.p1[0]), float(taper.p1[1]), float(taper.p1[2])]
elif cyl.p1 is not None and len(cyl.p1) >= 3:
dest = [float(cyl.p1[0]), float(cyl.p1[1]), float(cyl.p1[2])]
if dest is not None:
mid = _calf_split_mid(list(cyl.p0), dest)
cyl.p1 = list(mid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy calf capsule shortened

Low Severity

When no calf_taper_dist exists, _apply_measured_calf_cyl_y still runs _calf_split_mid and writes the result into calf_cyl.p1. That turns a full-shank one-cyl capsule into a proximal 42% stub and leaves a gap to the ankle. Sibling thigh-adduction logic correctly leaves legacy p1 at the ankle and only moves p0.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8d80d1b. Configure here.

if taper is not None:
taper.p0 = list(mid)
taper.placement = "full3d"
messages.append(f"leg_foot: measured calf_cyl y={my:.4f} ({side})")


def _noskel_arm_endpoint_ys(
band_id: str,
y0: float | None,
Expand Down Expand Up @@ -4671,6 +4702,8 @@ def build_blockout_recipe(

# 0034 B6 / 0096: distal/taper p1 Y ← ank_foot after feet (legacy: cyl.p1)
_sync_calf_distal_to_ankle(parts, messages)
# 0127: measured gastroc Y after B6 (B33 — then recompute mid; B32 — do not abs).
_apply_measured_calf_cyl_y(parts, report, messages)

# 0027 profile emit after base (skip_roles already applied)
if profile is not None:
Expand Down Expand Up @@ -7278,6 +7311,7 @@ def run_blockout_emit_setup(
"_apply_glute_seat_mass",
"_apply_head_pitch",
"_apply_join_ready_overlaps",
"_apply_measured_calf_cyl_y",
"_apply_mid_back_plane",
"_apply_neck_column_priors",
"_apply_neck_diameter_base",
Expand Down
19 changes: 19 additions & 0 deletions src/meshops/proportion/extremity_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,15 @@ def finger_primary_axis(
# ---------------------------------------------------------------------------


def _measured_arch_z_m(report: ProportionReport, side: str) -> float | None:
"""Finite arch_apex_{side} z_m, else None (0127 — never invent)."""
lm = report.landmarks_xyz.get(f"arch_apex_{side}")
if lm is None or lm.z_m is None:
return None
val = float(lm.z_m)
return val if math.isfinite(val) else None


def build_foot_parts(
report: ProportionReport,
*,
Expand Down Expand Up @@ -973,6 +982,16 @@ def _build_foot_side(
parent_joint=pj_ank,
)
)
# 0127: measured arch_apex Z after arch write (keep ARCH_SOFT_RY_FRAC_HALF_DEPTH).
arch_part = out[-1]
measured_z = _measured_arch_z_m(report, side)
if measured_z is not None and arch_part.center is not None and len(arch_part.center) >= 3:
arch_part.center = [
float(arch_part.center[0]),
float(arch_part.center[1]),
measured_z,
]
messages.append(f"leg_foot: measured arch z={measured_z:.4f} ({side})")

# ank_foot — name MUST contain ank_foot (B2)
ank_name = f"RECIPE_ank_foot_{side}"
Expand Down
14 changes: 11 additions & 3 deletions src/meshops/proportion/fuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
FACE_LEFT_LANDMARK_IDS,
HIP_GLUTE_BACK_LANDMARK_IDS,
HIP_GLUTE_LEFT_LANDMARK_IDS,
LEG_FOOT_BACK_LANDMARK_IDS,
LEG_FOOT_LEFT_LANDMARK_IDS,
TORSO_BACK_LANDMARK_IDS,
TORSO_LEFT_LANDMARK_IDS,
)
Expand Down Expand Up @@ -206,7 +208,8 @@ def fuse_xyz(
xyz.z_m = z * height_m
out[lid] = xyz

# 0125/0126: back-view X/Z for torso + hip/glute form-read ids (not DEPTH_PAIRS; Y from left).
# 0125/0126/0127: back-view X/Z for torso + hip/glute + leg/foot form-read ids
# (not DEPTH_PAIRS; Y from left).
back = views.get("back")
if back is not None and back.landmarks:
back_span = back.figure_span_px or figure_span_from_landmarks(back)
Expand All @@ -217,7 +220,11 @@ def fuse_xyz(
back.figure_span_px = back_span
back_mid = _midline_x(back)
invert_x = str(back.facing_direction or "camera_back") == "camera_back"
for lid in (*TORSO_BACK_LANDMARK_IDS, *HIP_GLUTE_BACK_LANDMARK_IDS):
for lid in (
*TORSO_BACK_LANDMARK_IDS,
*HIP_GLUTE_BACK_LANDMARK_IDS,
*LEG_FOOT_BACK_LANDMARK_IDS,
):
src_lm = back.landmarks.get(lid)
if src_lm is None:
continue
Expand Down Expand Up @@ -341,11 +348,12 @@ def fuse_xyz(
mid.x_m = x_ref * height_m
out[mid_id] = mid

# 0124/0125/0126: same-id left overlay for face + torso + hip/glute Y.
# 0124/0125/0126/0127: same-id left overlay for face + torso + hip/glute + leg/foot Y.
for lid in (
*FACE_LEFT_LANDMARK_IDS,
*TORSO_LEFT_LANDMARK_IDS,
*HIP_GLUTE_LEFT_LANDMARK_IDS,
*LEG_FOOT_LEFT_LANDMARK_IDS,
):
src_lm = left.landmarks.get(lid)
if src_lm is None:
Expand Down
1 change: 1 addition & 0 deletions src/meshops/proportion/honesty.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
FACE_COMPARE_HONESTY = "proportion_face_compare_not_mesh_or_print_success"
TORSO_COMPARE_HONESTY = "proportion_torso_compare_not_mesh_or_print_success"
HIP_GLUTE_COMPARE_HONESTY = "proportion_hip_glute_compare_not_mesh_or_print_success"
LEG_FOOT_COMPARE_HONESTY = "proportion_leg_foot_compare_not_mesh_or_print_success"
TEMPLATE_HONESTY = "proportion_body_template_not_mesh_or_print_success"
CONSTRAINT_HONESTY = "proportion_blockout_constraints_not_mesh_or_print_success"
OPTIMIZE_HONESTY = "proportion_blockout_optimize_not_mesh_or_print_success"
Expand Down
Loading