diff --git a/Exercise Library/SYSTEM.md b/Exercise Library/SYSTEM.md index f67e280..0b2c1d9 100644 --- a/Exercise Library/SYSTEM.md +++ b/Exercise Library/SYSTEM.md @@ -80,6 +80,16 @@ never touches a motion script** — proportions are the skeleton's problem. camera also orbits while the motion loops (`--orbit`; the in-app renderer slowly orbits **every** exercise, machines included — props have world-space 3D form and turn with the figure). +- **Zoom** — the skeleton is ~211 canvas units tall standing but the canvas + has only 152 above the ground line, so standing motions author full-size + anatomy (coordinates may run past the canvas top) and set + `"camera": {"zoom": 0.7}` to fit. Zoom is **presentation-only**: a uniform + scale of the drawn output (geometry, props, mat, stroke widths) about the + ground-center anchor `(160, 152)`, applied after all solving — pins, prop + coordinates, and the Swift-solver fixtures stay in full-size authored + units, and the ground line maps to itself so planted feet stay planted. + Typical values: ~0.7 standing tall, ~0.75–0.85 hanging or seated with arms + overhead; omit it (1) for lying, kneeling, seated, and bent-over motions. - **Elevation & the mat** — the default viewpoint pitches down 10° (`CAMERA_PITCH`; override per motion via `"camera": {"pitch": ...}`), and the ground is drawn as an **exercise mat**: a world-space quad sized to the diff --git a/Exercise Library/render.py b/Exercise Library/render.py index 7c1c04f..14d08c4 100644 --- a/Exercise Library/render.py +++ b/Exercise Library/render.py @@ -210,6 +210,48 @@ def mat_bounds(norms, prof, cam, pitch=CAMERA_PITCH): return lo - 12, hi + 12 +ZOOM_ANCHOR = (CANVAS[0] / 2.0, float(GROUND_Y)) + + +def apply_zoom(geo, prims, zoom): + """Presentation-only camera zoom about the ground-center anchor: scales the + drawn figure, equipment, and stroke widths so standing-height motions fit + the canvas. Applied after all solving — authored coordinates, pins, props, + and the fixture geometry stay in full-size canvas units (the in-app + renderer applies the same transform at draw time).""" + if zoom == 1.0: + return geo, prims + ax, ay = ZOOM_ANCHOR + + def pt(p): + return (ax + (p[0] - ax) * zoom, ay + (p[1] - ay) * zoom) + + g = dict(geo) + g["zoom"] = geo.get("zoom", 1.0) * zoom + g["headR"] = geo["headR"] * zoom + g["head"] = pt(geo["head"]) + for key in ("spine", "girdle", "pelvisBar", "floor") + tuple(K.LIMBS): + if key in g: + g[key] = [pt(p) for p in geo[key]] + if "nose" in geo: + g["nose"] = (pt(geo["nose"][0]), pt(geo["nose"][1])) + + def zp(prim): + q = dict(prim) + if "pts" in q: + q["pts"] = [list(pt(p)) for p in q["pts"]] + if "c" in q: + q["c"] = list(pt(q["c"])) + if "r" in q: + q["r"] = q["r"] * zoom + if "w" in q: + q["w"] = q["w"] * zoom + return q + + bg, fg = prims + return g, ([zp(p) for p in bg], [zp(p) for p in fg]) + + def ease(t): return 3 * t * t - 2 * t * t * t @@ -423,7 +465,7 @@ def draw_prims(d, prims, colors, scale): if p["kind"] == "poly": d.polygon(pts, fill=color) pts = pts + pts[:1] - w = p.get("w", 4) * scale + w = round(p.get("w", 4) * scale) d.line(pts, fill=color, width=w, joint="curve") for x, y in (pts[0], pts[-1]): d.ellipse([x - w / 2, y - w / 2, x + w / 2, y + w / 2], fill=color) @@ -434,7 +476,7 @@ def draw_prims(d, prims, colors, scale): d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=color) else: d.ellipse([cx - r, cy - r, cx + r, cy + r], outline=color, - width=p.get("w", 3) * scale) + width=round(p.get("w", 3) * scale)) # ------------------------------------------------------------------- drawing @@ -447,7 +489,7 @@ def floor_svg(geo, colors): return "" d = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in quad) + " Z" return (f' ') + f' stroke-width="{3 * geo.get("zoom", 1.0):g}" stroke-linejoin="round"/>') def part_style(part, working, colors, shade): @@ -472,6 +514,7 @@ def quad_points(p0, ctrl, p2, n=24): 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'', f' {name}', floor_svg(geo, colors)] @@ -481,25 +524,26 @@ def svg_for_frame(name, geo, order, shade, working, colors, prims=((), ())): parts += svg_prims(fg, colors) hx, hy = geo["head"] parts.append(f' ') + f' fill="{colors["head_fill"]}" stroke="{colors["right"]}" stroke-width="{WIDTHS["head"] * zf:g}"/>') if "nose" in geo: (sx, sy), (ex, ey) = geo["nose"] parts.append(f' ') + f' stroke="{colors["right"]}" stroke-width="{WIDTHS["nose"] * zf:g}" stroke-linecap="round"/>') continue if part not in geo: continue color, width = part_style(part, working, colors, shade) + width *= zf if part == "spine": for bar in ("girdle", "pelvisBar"): bd = "M " + " L ".join(f"{x:.1f} {y:.1f}" for x, y in geo[bar]) - parts.append(f' ') (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' ') lx = w - 96 parts.append(f' ') @@ -517,35 +561,37 @@ def draw_geo(geo, order, shade, working, colors, scale=2, font=None, prims=((), bg, fg = prims w, h = CANVAS[0] * scale, CANVAS[1] * scale + zf = geo.get("zoom", 1.0) img = Image.new("RGB", (w, h), "white") d = ImageDraw.Draw(img) def line(pts, color, width): pts = [(x * scale, y * scale) for x, y in pts] - d.line(pts, fill=color, width=width * scale, joint="curve") + d.line(pts, fill=color, width=round(width * scale), joint="curve") r = width * scale / 2 for x, y in (pts[0], pts[-1]): d.ellipse([x - r, y - r, x + r, y + r], fill=color) if geo.get("floor"): - line(geo["floor"] + geo["floor"][:1], colors["ground"], 3) + line(geo["floor"] + geo["floor"][:1], colors["ground"], 3 * zf) draw_prims(d, bg, colors, scale) for part in order: if part == "head": draw_prims(d, fg, colors, scale) hx, hy = geo["head"] - r, sw = geo["headR"] * scale, WIDTHS["head"] * scale + r, sw = geo["headR"] * scale, round(WIDTHS["head"] * zf * scale) d.ellipse([hx * scale - r, hy * scale - r, hx * scale + r, hy * scale + r], fill=colors["head_fill"], outline=colors["right"], width=sw) if "nose" in geo: - line(list(geo["nose"]), colors["right"], WIDTHS["nose"]) + line(list(geo["nose"]), colors["right"], WIDTHS["nose"] * zf) continue if part not in geo: continue color, width = part_style(part, working, colors, shade) + width *= zf if part == "spine": - line(geo["girdle"], color, 5) - line(geo["pelvisBar"], color, 5) + 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) @@ -575,12 +621,13 @@ def load_motion(folder): def prepare(motion, figure="neutral", flip=False, strict=False): - """Load a motion into (normalized frames, profile, camera yaw, props), - validating each key frame against the skeleton's ROM.""" + """Load a motion into (normalized frames, profile, camera yaw, pitch, + props, zoom), validating each key frame against the skeleton's ROM.""" skel = K.load_skeleton() prof = skel["profiles"][figure] cam = float(motion.get("camera", {}).get("yaw", 0.0)) + (180.0 if flip else 0.0) pitch = float(motion.get("camera", {}).get("pitch", CAMERA_PITCH)) + zoom = float(motion.get("camera", {}).get("zoom", 1.0)) norms = [K.normalize_frame(kf) for kf in motion["frames"]] issues = [] for i, nf in enumerate(norms, start=1): @@ -596,13 +643,13 @@ def prepare(motion, figure="neutral", flip=False, strict=False): if flip: norms = [mirror_frame(nf, CANVAS[0]) for nf in norms] props = flip_props(props, CANVAS[0]) - return norms, prof, cam, pitch, props + return norms, prof, cam, pitch, props, zoom def render_exercise(folder, figure="neutral", flip=False, strict=False): motion = load_motion(folder) working = set(motion.get("working", [])) - norms, prof, cam, pitch, props = prepare(motion, figure, flip, strict) + norms, prof, cam, pitch, props, zoom = prepare(motion, figure, flip, strict) mat = mat_bounds(norms, prof, cam, pitch) @@ -615,7 +662,9 @@ def render_exercise(folder, figure="neutral", flip=False, strict=False): for nf in norms: out, geo, order, shade = frame_geometry(nf, prof, cam, flip, pitch, mat) resolved.append(out) - key_cells.append((geo, order, shade, resolve_props(props, geo, nf["root"]["pos"]))) + prims = resolve_props(props, geo, nf["root"]["pos"]) + geo, prims = apply_zoom(geo, prims, zoom) + key_cells.append((geo, order, shade, prims)) frames_dir = folder / "frames" frames_dir.mkdir(exist_ok=True) @@ -634,6 +683,7 @@ def render_exercise(folder, figure="neutral", flip=False, strict=False): for nf in timeline(resolved): geo, order, shade = geometry(nf) prims = resolve_props(props, geo, nf["root"]["pos"]) + geo, prims = apply_zoom(geo, prims, zoom) imgs.append(draw_geo(geo, order, shade, working, colors, font=font, prims=prims)) imgs[0].save(folder / "preview.gif", save_all=True, append_images=imgs[1:], duration=50, loop=0) @@ -648,7 +698,7 @@ def render_orbit(folder, figure="neutral"): motion authors one). Props rotate with the figure about the root anchor.""" motion = load_motion(folder) working = set(motion.get("working", [])) - norms, prof, cam, pitch, props = prepare(motion, figure) + norms, prof, cam, pitch, props, zoom = prepare(motion, figure) resolved = [frame_geometry(nf, prof, cam, pitch=pitch)[0] for nf in norms] mat = mat_bounds(norms, prof, cam, pitch) sweep = motion.get("camera", {}).get("sweep") @@ -667,6 +717,7 @@ def render_orbit(folder, figure="neutral"): _, geo, order, shade = frame_geometry(posed, prof, cam + off, pitch=pitch, mat=mat) prims = resolve_props(props, geo, nf["root"]["pos"], prop_rotation(pitch, off), auth_geo) + geo, prims = apply_zoom(geo, prims, zoom) imgs.append(draw_geo(geo, order, shade, working, colors, font=font, prims=prims)) imgs[0].save(folder / "orbit.gif", save_all=True, append_images=imgs[1:], duration=50, loop=0) @@ -679,11 +730,12 @@ def contact_sheet(folders, figure="neutral", out=None): for folder in folders: motion = load_motion(folder) working = set(motion.get("working", [])) - norms, prof, cam, pitch, props = prepare(motion, figure) + norms, prof, cam, pitch, props, zoom = prepare(motion, figure) mat = mat_bounds(norms, prof, cam, pitch) for i, nf in enumerate(norms, start=1): _, geo, order, shade = frame_geometry(nf, prof, cam, pitch=pitch, mat=mat) prims = resolve_props(props, geo, nf["root"]["pos"]) + geo, prims = apply_zoom(geo, prims, zoom) cells.append((f"{motion['name']} {i}/{len(norms)}", draw_geo(geo, order, shade, working, PALETTES["default"], font=font, prims=prims))) @@ -705,10 +757,11 @@ def demo_sheet(folder): ("themed (indigo)", "neutral", False, "indigo")] cells = [] for label, figure, flip, palette in variants: - norms, prof, cam, pitch, props = prepare(motion, figure, flip) + norms, prof, cam, pitch, props, zoom = prepare(motion, figure, flip) mat = mat_bounds(norms, prof, cam, pitch) _, geo, order, shade = frame_geometry(norms[idx], prof, cam, flip, pitch, mat) prims = resolve_props(props, geo, norms[idx]["root"]["pos"]) + geo, prims = apply_zoom(geo, prims, zoom) cells.append((f"{motion['name']} — {label}", draw_geo(geo, order, shade, working, PALETTES[palette], font=font, prims=prims))) @@ -780,7 +833,7 @@ def write_fixtures(folders): exercises = [] for folder in folders: motion = load_motion(folder) - norms, prof, cam, pitch, props = prepare(motion, "neutral") + norms, prof, cam, pitch, props, _zoom = prepare(motion, "neutral") resolved, frames = [], [] for nf in norms: out, geo, order, shade = frame_geometry(nf, prof, cam) diff --git a/Workouts/ExerciseFigure/ExerciseFigureView.swift b/Workouts/ExerciseFigure/ExerciseFigureView.swift index ed12ffb..fac4b6d 100644 --- a/Workouts/ExerciseFigure/ExerciseFigureView.swift +++ b/Workouts/ExerciseFigure/ExerciseFigureView.swift @@ -24,6 +24,9 @@ struct FigureAnimation { let working: Set /// Equipment layer (see SYSTEM.md "The props layer"). let props: [MotionProp] + /// Presentation zoom about the ground-center anchor — standing motions author + /// full-size anatomy taller than the canvas and zoom out to fit (see SYSTEM.md). + let zoom: Double init?(exerciseName: String) { guard @@ -33,6 +36,7 @@ struct FigureAnimation { self.timeline = timeline self.working = Set(resources.motion.working ?? []) self.props = resources.motion.props ?? [] + self.zoom = resources.motion.camera?.zoom ?? 1 } /// Seconds per full camera revolution. @@ -96,6 +100,14 @@ struct ExerciseFigureView: View { y: (size.height - Self.designSize.height * scale) / 2) ctx.scaleBy(x: scale, y: scale) + // Presentation zoom about the ground-center anchor, mirroring the reference + // renderer's `apply_zoom` (stroke widths scale with the geometry). + if figure.zoom != 1 { + ctx.translateBy(x: Self.designSize.width / 2, y: Self.groundY) + ctx.scaleBy(x: figure.zoom, y: figure.zoom) + ctx.translateBy(x: -Self.designSize.width / 2, y: -Self.groundY) + } + let geo = figure.geometry(at: time) // The exercise mat: a ground-plane quad sized to the motion's footprint, diff --git a/Workouts/ExerciseFigure/ExerciseMotion.swift b/Workouts/ExerciseFigure/ExerciseMotion.swift index b0741f3..b9dc198 100644 --- a/Workouts/ExerciseFigure/ExerciseMotion.swift +++ b/Workouts/ExerciseFigure/ExerciseMotion.swift @@ -106,6 +106,10 @@ struct MotionCamera: Codable { let yaw: Double? /// Camera elevation override; nil uses the standard slightly-raised viewpoint. let pitch: Double? + /// Presentation-only zoom about the ground-center anchor (nil = 1). Standing + /// motions author full-size anatomy — taller than the canvas — and zoom out + /// to fit; the solver and fixtures stay in full-size canvas units. + let zoom: Double? } /// A prop's joint reference: one joint (`"hand_r"`, `"knee_l"`, `"elbow_r"`, …)