Add Warm-Up and Stretching activity types

Adds WorkoutActivityType.warmUp (HealthKit .preparationAndRecovery) and
.stretching (.flexibility), and retags the six starter splits that were all
mislabeled as Functional Strength:

- Warm-Up:    Upper Body Warm-Up, Lower Body Warm-Up, Morning Wake-Up
- Stretching: Morning Mobility, Full Body Stretch, Evening Stretch

The split editor's activity picker surfaces them automatically (CaseIterable).
Older app versions decode the new raw values as the default type — additive and
not schema-gated, so no quarantine.
This commit is contained in:
2026-07-09 15:45:31 -04:00
parent f01e149269
commit e12fe31152
308 changed files with 5594 additions and 990 deletions
+119 -24
View File
@@ -53,6 +53,15 @@ PALETTES = {
}
WIDTHS = {"near": 6, "far": 5, "spine": 6, "head": 6, "nose": 4}
# Depth shading is a smooth per-vertex gradient, not a near/far switch: every
# drawn joint is blended between the near and far colors by its own camera depth
# relative to its limb pair's central plane, so the ink flows along a bone that
# reaches in depth and fades to a shared mid-tone where the two members cross
# (face-on, or mid-orbit) instead of snapping. A joint reaches full contrast
# once it sits this fraction of the shoulder/pelvis half-width in front of (or
# behind) that plane, so a straight limb in profile is still fully dark/light.
SHADE_SPAN_FRAC = 0.6
# Draw order is by camera depth (far parts first, head always on top, filled
# opaque so overhead arms are occluded by the face). Depths are bucketed so
# side views stay stable; ties fall back to this fixed rank.
@@ -116,7 +125,7 @@ def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH, mat=None):
distinguishable in profile views.
"""
p0 = K.pose(nf, prof, cam, pitch)
shade, order_parts = {}, []
shade = {}
for right, left in PAIRS:
dr, dl = _chain_depth(p0["points"][right]), _chain_depth(p0["points"][left])
if _bucket(dr) == _bucket(dl):
@@ -160,6 +169,26 @@ def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH, mat=None):
geo[limb] = [scr(v, limb_off) for v in pts[limb]]
depths[limb] = _chain_depth(pts[limb])
# Per-vertex depth shading: each drawn joint is toned by its OWN camera depth,
# not the whole limb's average, so the ink flows smoothly along a limb that
# reaches toward or away from the camera instead of the bone snapping to one
# flat tone. Each pair is measured against its central depth plane and
# normalized by the profile half-width (x SHADE_SPAN_FRAC), chosen so a
# straight limb in a profile view still reaches full near-dark / far-light
# (the per-vertex average reproduces the old flat per-limb tone) while the
# two members still fade to a shared mid-tone as they cross. The binary
# `shade` above stays untouched - it alone drives the far offset and order.
nearness = {}
for right, left in PAIRS:
zr = [v[2] for v in pts[right]]
zl = [v[2] for v in pts[left]]
ref = (sum(zr) / len(zr) + sum(zl) / len(zl)) / 2
half = prof.get("shoulderHalf" if "arm" in right else "hipHalf", 10) * p["k"]
span = half * SHADE_SPAN_FRAC
for limb, zs in ((right, zr), (left, zl)):
nearness[limb] = [(0.5 + 0.5 * max(-1.0, min(1.0, (z - ref) / span)))
if span else 0.5 for z in zs]
# Shoulder girdle and pelvis, drawn with the spine so the limbs visibly hang
# from real width. Endpoints use each side's drawn attach (far offset included)
# so the bars meet the limbs exactly.
@@ -192,6 +221,7 @@ def frame_geometry(nf, prof, cam, flipped=False, pitch=CAMERA_PITCH, mat=None):
geo["nose"] = ((hx + ux * r, hy + uy * r),
(hx + ux * (r + 7 * mag), hy + uy * (r + 7 * mag)))
geo["nearness"] = nearness
order = sorted(depths, key=lambda part: (_bucket(depths[part]),
FIXED_RANK[part])) + ["head"]
return work, geo, order, shade
@@ -504,14 +534,35 @@ def floor_svg(geo, colors):
f' stroke-width="{3 * geo.get("zoom", 1.0):g}" stroke-linejoin="round"/>')
def part_style(part, working, colors, shade):
"""Near pair members draw in the dark ink, far members in the light one;
the spine is always dark. Working parts swap ink for the accent teals."""
tone = shade.get(part, "near")
key = "right" if tone == "near" else "left"
color = colors[f"{key}_working"] if part in working else colors[key]
width = WIDTHS["spine"] if part == "spine" else WIDTHS[tone]
return color, width
def _lerp_hex(a, b, t):
"""Blend two "#rrggbb" colors; t=0 -> a, t=1 -> b."""
a, b = a.lstrip("#"), b.lstrip("#")
ar, ag, ab = int(a[0:2], 16), int(a[2:4], 16), int(a[4:6], 16)
br, bg, bb = int(b[0:2], 16), int(b[2:4], 16), int(b[4:6], 16)
return "#%02x%02x%02x" % (round(ar + (br - ar) * t),
round(ag + (bg - ag) * t),
round(ab + (bb - ab) * t))
def shade_ink(part, working, colors, t):
"""The ink at nearness `t` (1 = near/dark, 0 = far/light): the plain near/far
grays, or the accent teals when `part` is a working part."""
far, near = ("left_working", "right_working") if part in working else ("left", "right")
return _lerp_hex(colors[far], colors[near], t)
def shade_width(t):
"""Stroke width tracking nearness (far 5 -> near 6)."""
return WIDTHS["far"] + (WIDTHS["near"] - WIDTHS["far"]) * t
def part_style(part, working, colors, nearness):
"""Single-tone style for the spine and its bars — the near ink (or working
teal), at the spine width. (Limbs shade per vertex; see `svg_for_frame`.)"""
t = nearness.get(part, 1.0)
if isinstance(t, list):
t = sum(t) / len(t) if t else 1.0
return shade_ink(part, working, colors, t), WIDTHS["spine"]
def quad_points(p0, ctrl, p2, n=24):
@@ -527,9 +578,7 @@ def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
bg, fg = prims
w, h = CANVAS
zf = geo.get("zoom", 1.0)
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
f' <title>{name}</title>',
floor_svg(geo, colors)]
defs, parts = [], [floor_svg(geo, colors)]
parts += svg_prims(bg, colors)
for part in order:
if part == "head":
@@ -544,19 +593,41 @@ def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
continue
if part not in geo:
continue
color, width = part_style(part, working, colors, shade)
width *= zf
if part == "spine":
color, _ = part_style(part, working, colors, geo["nearness"])
for bar in ("girdle", "pelvisBar"):
bd = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[bar])
parts.append(f' <path id="{bar}" d="{bd}" stroke="{color}" stroke-width="{5 * zf:g}"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
(ax, ay), (cx, cy), (bx, by) = geo["spine"]
d = f"M {ax:.1f} {ay:.1f} Q {cx:.1f} {cy:.1f} {bx:.1f} {by:.1f}"
else:
d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[part])
parts.append(f' <path id="{part}" d="{d}" stroke="{color}" stroke-width="{width:g}"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
parts.append(f' <path id="spine" d="M {ax:.1f} {ay:.1f} Q {cx:.1f} {cy:.1f} {bx:.1f} {by:.1f}"'
f' stroke="{color}" stroke-width="{WIDTHS["spine"] * zf:g}"'
f' stroke-linecap="round" stroke-linejoin="round"/>')
continue
# A limb shades per vertex: each bone segment is its own round-capped path,
# stroked with a linear gradient between its two joints' depth tones so the
# ink flows smoothly along the limb. Adjacent segments share a joint tone,
# so the round caps meet seamlessly.
chain = geo[part]
tones = geo["nearness"].get(part) or [1.0] * len(chain)
parts.append(f' <g id="{part}">')
for i in range(len(chain) - 1):
(x0, y0), (x1, y1) = chain[i], chain[i + 1]
c0 = shade_ink(part, working, colors, tones[i])
c1 = shade_ink(part, working, colors, tones[i + 1])
sw = shade_width((tones[i] + tones[i + 1]) / 2) * zf
if c0 == c1 or (abs(x1 - x0) < 0.01 and abs(y1 - y0) < 0.01):
stroke = c0
else:
gid = f"grad-{part}-{i}"
defs.append(f' <linearGradient id="{gid}" gradientUnits="userSpaceOnUse"'
f' x1="{x0:.1f}" y1="{y0:.1f}" x2="{x1:.1f}" y2="{y1:.1f}">'
f'<stop offset="0" stop-color="{c0}"/>'
f'<stop offset="1" stop-color="{c1}"/></linearGradient>')
stroke = f"url(#{gid})"
parts.append(f' <path d="M {x0:.1f} {y0:.1f} L {x1:.1f} {y1:.1f}"'
f' stroke="{stroke}" stroke-width="{sw:g}" stroke-linecap="round"/>')
parts.append(' </g>')
lx = w - 96
parts.append(f' <g font-family="-apple-system, Helvetica, sans-serif" font-size="11" fill="{colors["legend_text"]}">')
parts.append(f' <line x1="{lx}" y1="16" x2="{lx + 14}" y2="16" stroke="{colors["right"]}" stroke-width="4" stroke-linecap="round"/>')
@@ -565,7 +636,11 @@ def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())):
parts.append(f' <text x="{lx + 68}" y="20">far</text>')
parts.append(' </g>')
parts.append('</svg>')
return "\n".join(parts) + "\n"
header = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {w} {h}" fill="none">',
f' <title>{name}</title>']
if defs:
header += [' <defs>'] + defs + [' </defs>']
return "\n".join(header + parts) + "\n"
def draw_geo(geo, order, shade, working, colors, scale=2, font=None, prims=((), ())):
@@ -584,6 +659,25 @@ def draw_geo(geo, order, shade, working, colors, scale=2, font=None, prims=((),
for x, y in (pts[0], pts[-1]):
d.ellipse([x - r, y - r, x + r, y + r], fill=color)
def gradient_line(pts, tones, part):
"""Stroke a limb chain with a per-vertex depth gradient: subdivide each
bone into short steps whose ink and width interpolate between the two
joints' tones, capping every sample so the stroke stays continuous."""
steps = 12
prev = None
for i in range(len(pts) - 1):
(x0, y0), (x1, y1) = pts[i], pts[i + 1]
for s in range(steps + 1):
u = s / steps
x, y = (x0 + (x1 - x0) * u) * scale, (y0 + (y1 - y0) * u) * scale
t = tones[i] + (tones[i + 1] - tones[i]) * u
col = shade_ink(part, working, colors, t)
r = shade_width(t) * zf * scale / 2
if prev is not None:
d.line([prev, (x, y)], fill=col, width=round(2 * r))
d.ellipse([x - r, y - r, x + r, y + r], fill=col)
prev = (x, y)
if geo.get("floor"):
line(geo["floor"] + geo["floor"][:1], colors["ground"], 3 * zf)
draw_prims(d, bg, colors, scale)
@@ -599,13 +693,14 @@ def draw_geo(geo, order, shade, working, colors, scale=2, font=None, prims=((),
continue
if part not in geo:
continue
color, width = part_style(part, working, colors, shade)
width *= zf
if part == "spine":
color, _ = part_style(part, working, colors, geo["nearness"])
line(geo["girdle"], color, 5 * zf)
line(geo["pelvisBar"], color, 5 * zf)
pts = quad_points(*geo["spine"]) if part == "spine" else geo[part]
line(pts, color, width)
line(quad_points(*geo["spine"]), color, WIDTHS["spine"] * zf)
continue
tones = geo["nearness"].get(part) or [1.0] * len(geo[part])
gradient_line(geo[part], tones, part)
lx = CANVAS[0] - 96
line([(lx, 16), (lx + 14, 16)], colors["right"], 4)