diff --git a/.gitignore b/.gitignore index abe251c..9a58353 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ test-results playwright-report .playwright-mcp/ src/types/ocf.generated.ts +screenshots/ diff --git a/scripts/shoot-fixture.mjs b/scripts/shoot-fixture.mjs new file mode 100644 index 0000000..4928bc9 --- /dev/null +++ b/scripts/shoot-fixture.mjs @@ -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 [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 [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(); diff --git a/src/__fixtures__/around-player-arc.json b/src/__fixtures__/around-player-arc.json new file mode 100644 index 0000000..bde43c5 --- /dev/null +++ b/src/__fixtures__/around-player-arc.json @@ -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" } } + } + ] +} diff --git a/src/actions/resolve-action-path.test.ts b/src/actions/resolve-action-path.test.ts index b138999..ab10a63 100644 --- a/src/actions/resolve-action-path.test.ts +++ b/src/actions/resolve-action-path.test.ts @@ -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", () => { @@ -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); }); }); }); diff --git a/src/actions/resolve-action-path.ts b/src/actions/resolve-action-path.ts index 5a4ba21..65ab8c3 100644 --- a/src/actions/resolve-action-path.ts +++ b/src/actions/resolve-action-path.ts @@ -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 { @@ -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; diff --git a/src/actions/wavy-line.ts b/src/actions/wavy-line.ts index 592a12d..316a498 100644 --- a/src/actions/wavy-line.ts +++ b/src/actions/wavy-line.ts @@ -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; diff --git a/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-0-chromium-darwin.png b/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-0-chromium-darwin.png index d0e3a93..1284a74 100644 Binary files a/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-0-chromium-darwin.png and b/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-0-chromium-darwin.png differ diff --git a/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-1-chromium-darwin.png b/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-1-chromium-darwin.png index 9632ef5..4699b9d 100644 Binary files a/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-1-chromium-darwin.png and b/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-1-chromium-darwin.png differ diff --git a/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-2-chromium-darwin.png b/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-2-chromium-darwin.png index ed64467..ef64876 100644 Binary files a/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-2-chromium-darwin.png and b/tests/visual/tactical-print.spec.ts-snapshots/pick-and-roll-2-chromium-darwin.png differ diff --git a/tests/visual/tactical-print.spec.ts-snapshots/source-four-frame-ball-screen-4-chromium-darwin.png b/tests/visual/tactical-print.spec.ts-snapshots/source-four-frame-ball-screen-4-chromium-darwin.png index 91c80f7..7da385b 100644 Binary files a/tests/visual/tactical-print.spec.ts-snapshots/source-four-frame-ball-screen-4-chromium-darwin.png and b/tests/visual/tactical-print.spec.ts-snapshots/source-four-frame-ball-screen-4-chromium-darwin.png differ