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:
+119
-24
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user