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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ test-results
playwright-report
.playwright-mcp/
src/types/ocf.generated.ts
screenshots/
53 changes: 53 additions & 0 deletions scripts/shoot-fixture.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Dev-only screenshot helper: renders a fixture (optionally a specific frame)
// to a PNG so we can eyeball the dribble/around-player output and iterate.
// Usage: node scripts/shoot-fixture.mjs <fixture> [frame] [outPath]
// Requires: npm run build (so dist/ + the browser bundle exist) first.
import { createServer } from "node:http";
import { readFileSync, existsSync } from "node:fs";
import { extname, join, resolve, sep } from "node:path";
import { chromium } from "playwright";

const root = resolve(import.meta.dirname, "..");
const [, , fixture, frameArg, outArg] = process.argv;
if (!fixture) {
console.error("usage: node scripts/shoot-fixture.mjs <fixture> [frame] [outPath]");
process.exit(1);
}
const frame = frameArg ?? "0";
const out = outArg ?? join(root, "screenshots", `${fixture}-f${frame}.png`);

const MIME = {
".html": "text/html", ".js": "text/javascript", ".mjs": "text/javascript",
".json": "application/json", ".css": "text/css", ".png": "image/png",
};

const server = createServer((req, res) => {
const urlPath = decodeURIComponent(req.url.split("?")[0]);
// Resolve within root and confirm the result stays inside it, so a crafted
// `../` path can't escape the served directory (path-traversal guard).
const filePath = resolve(root, "." + urlPath);
const rootPrefix = root.endsWith(sep) ? root : root + sep;
if (filePath !== root && !filePath.startsWith(rootPrefix)) {
res.writeHead(403); res.end("forbidden"); return;
}
if (!existsSync(filePath)) { res.writeHead(404); res.end("not found"); return; }
res.writeHead(200, { "Content-Type": MIME[extname(filePath)] ?? "application/octet-stream" });
res.end(readFileSync(filePath));
});

await new Promise((r) => server.listen(4173, r));
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 820, height: 620 } });
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto(`http://localhost:4173/examples/render-fixture.html?fixture=${fixture}&frame=${frame}`);
try {
await page.waitForSelector("canvas[data-rendered='true']", { timeout: 8000 });
} catch {
console.error("render failed:", errors.join("; ") || "(no page errors)");
await browser.close(); server.close(); process.exit(1);
}
await page.locator("canvas").screenshot({ path: out });
console.log("wrote", out);
await browser.close();
server.close();
23 changes: 23 additions & 0 deletions src/__fixtures__/around-player-arc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://opencoachingformat.org/schema/v1.json",
"sport": "basketball",
"meta": { "id": "3c9f7a2e-4b1d-4e6f-8a0c-2d5e9a1b3c7f", "title": "Curl around a screen (side + arc)", "description": "Shooter curls tight around a down-screen; the action-level defaults are overridden on the curl step.", "created": "2026-08-28T10:00:00Z", "source_format": "open" },
"court": { "ruleset": "fiba", "type": "half_court", "drill_focus": "offense" },
"entities": [
{ "type": "offense", "nr": 2, "x": -6.75, "y": 13.98 },
{ "type": "offense", "nr": 5, "x": -2.45, "y": 8.2 }
],
"balls": [ { "id": "ball_1", "carried_by": "offense_2" } ],
"frames": [
{
"id": "f1",
"label": "Curl",
"description": "offense_2 curls tightly around offense_5's screen, passing on the right of the screener.",
"actions": [
{ "player": "offense_2", "type": "cut", "variant": "curl", "side": "left", "arc": "normal",
"moves": [ { "to": { "named": "right_elbow" }, "around_player": "offense_5", "side": "right", "arc": "tight", "intensity": "fast" } ] }
],
"end_state": { "offense_2": { "named": "right_elbow" } }
}
]
}
21 changes: 16 additions & 5 deletions src/actions/resolve-action-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,25 @@ describe("resolveActionPath", () => {
balls: {},
};

it("inserts an obstacle-clearing waypoint for around_player", () => {
it("inserts a three-point clearing arc around around_player", () => {
const action: Action = {
type: "cut",
player: "offense_1",
moves: [{ to: { x: 0, y: 11 }, around_player: "defense_1" }],
};
const path = resolveActionPath(action, aroundState, transformer)!;
expect(path.length).toBe(3);
// start, approach, apex, exit, destination
expect(path.length).toBe(5);
expect(path[0].equals(transformer.resolveToWorld({ x: 0, y: 5 }))).toBe(true);
expect(path[2].equals(transformer.resolveToWorld({ x: 0, y: 11 }))).toBe(true);
expect(path[1].x).not.toBeCloseTo(0, 5);
expect(path[path.length - 1].equals(transformer.resolveToWorld({ x: 0, y: 11 }))).toBe(true);
// Every interior waypoint is pushed off the (x = 0) travel line to one side.
for (let i = 1; i < path.length - 1; i++) expect(path[i].x).not.toBeCloseTo(0, 5);
const sign = Math.sign(path[1].x);
for (let i = 1; i < path.length - 1; i++) expect(Math.sign(path[i].x)).toBe(sign);
// The apex (middle) clears the obstacle by more than the player symbol radius.
const obstacle = transformer.resolveToWorld({ x: 0, y: 8 });
const apex = path[2];
expect(Math.abs(apex.x - obstacle.x)).toBeGreaterThan(0.5);
});

it("routes around the obstacle on a deterministic side for collinear points", () => {
Expand Down Expand Up @@ -171,7 +179,10 @@ describe("resolveActionPath", () => {
const custom = resolveActionPath(action, aroundState, transformer, {
arcDistances: { tight: 0.2, normal: 2.0, wide: 3.0 },
})!;
expect(custom[1].distanceTo(obstacleWorld)).toBeCloseTo(2.0, 5);
// For a head-on obstacle (collinear), the apex (middle point) sits exactly
// `distance` to the side of the obstacle centre.
const apex = custom[2];
expect(Math.abs(apex.x - obstacleWorld.x)).toBeCloseTo(2.0, 5);
});
});
});
37 changes: 29 additions & 8 deletions src/actions/resolve-action-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@ import * as THREE from "three";
import type { Action } from "../types/ocf";
import type { ResolvedFrameState } from "../parser/resolve-frame-state";
import { CoordinateTransformer } from "../court/coordinate-transformer";
import { OFFENSE_SYMBOL_RADIUS_M } from "../entities/offense-symbol";

/** How far (court units) the around_player detour waypoint sits from the
* obstacle, per `arc` value. The renderer owns this enum→distance mapping
* (RFC 0004); `normal` keeps the historical 0.6 so absent/`normal` arcs are
* unchanged. Overridable via {@link ResolveActionPathOptions.arcDistances}. */
* obstacle's CENTER, per `arc` value. The renderer owns this enum→distance
* mapping (RFC 0004). Values must clear the player symbol (radius
* {@link OFFENSE_SYMBOL_RADIUS_M} = 0.5 court units) with a visible margin, so
* the detour actually arcs *around* the player instead of cutting through the
* symbol. Overridable via {@link ResolveActionPathOptions.arcDistances}. */
export interface ArcDistances {
tight: number;
normal: number;
wide: number;
}

// Clearance = symbol radius + a per-arc gap (tight brushes past, wide swings out).
export const DEFAULT_ARC_DISTANCES: ArcDistances = {
tight: 0.35,
normal: 0.6,
wide: 0.9,
tight: OFFENSE_SYMBOL_RADIUS_M + 0.35, // ~0.85
normal: OFFENSE_SYMBOL_RADIUS_M + 0.7, // ~1.2
wide: OFFENSE_SYMBOL_RADIUS_M + 1.2, // ~1.7
};

export interface ResolveActionPathOptions {
Expand Down Expand Up @@ -71,8 +75,25 @@ function expandMoveSteps(
}
const arc = move.arc ?? "normal";
const distance = arcDistances[arc];
const waypoint = obstacle.clone().addScaledVector(side, distance);
points.push(waypoint);

// The apex sits right next to the obstacle, pushed `distance` to the chosen
// side of the player's centre — this is the "wrap around the player" point.
const apex = obstacle.clone().addScaledVector(side, distance);

// Round the corner: place an approach point between current→apex and an
// exit point between apex→destination, each nudged toward the apex side so
// the smoothing spline eases in and out instead of kinking at the apex.
// The nudge is a fraction of the clearance, capped so it can't overshoot
// short legs.
const easeSide = distance * 0.4;
const approachT = 0.5; // midpoint of the current→apex leg
const exitT = 0.5; // midpoint of the apex→destination leg
const approach = current.clone().lerp(apex, approachT).addScaledVector(side, easeSide);
const exit = apex.clone().lerp(destination, exitT).addScaledVector(side, easeSide);

points.push(approach);
points.push(apex);
points.push(exit);
}
points.push(destination);
current = destination;
Expand Down
9 changes: 5 additions & 4 deletions src/actions/wavy-line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import * as THREE from "three";
import { tangentBetween, perpendicularOf } from "../paths/tangent";
import { smoothPath, resamplePath } from "../paths/smooth-path";

// Tuned toward the values measured off TheHoopsGeek's dribble line (a calm,
// broad serpentine rather than a tight ripple). Court-unit scale.
const WAVELENGTH = 1.8; // broad waves (measured ~2.28)
const AMPLITUDE = 0.4; // (measured ~0.55); visibly waved but not cartoonish
// An even zig-zag texture *along* the dribble path — matching how coaching tools
// (see the reference drill PDFs) draw dribbles: regular, clearly-visible waves
// that read as "dribbling" without becoming a giant serpentine. Court-unit scale.
const WAVELENGTH = 0.9; // tight, regular waves — several crests over a typical dribble
const AMPLITUDE = 0.3; // clearly visible, matching the reference drills' zig-zag
// Resolution of the emitted curve — dense enough to read as a true curve.
const SAMPLES_PER_UNIT = 24;
const MIN_SAMPLES = 64;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.