diff --git a/src/court/curves.js b/src/court/curves.js index 96ac014..961aae6 100644 --- a/src/court/curves.js +++ b/src/court/curves.js @@ -4,10 +4,14 @@ */ /** - * Compute angle bisector control points for quadratic bezier interpolation. - * For waypoints P0, P1, ..., Pn: - * At each interior point Pi, compute angle bisector of incoming/outgoing vectors. - * + * Smooth quadratic spline through a set of waypoints. + * + * Each interior waypoint becomes the control point of a quadratic Bézier, and + * consecutive spans are joined at the segment midpoints so the curve passes + * smoothly *through* the waypoints and bulges toward them (the intuitive + * "outward" direction the author drew). The first span starts at P0 and the + * last span ends exactly on Pn, so endpoints are honored. + * * @param {Array<{x:number, y:number}>} points - Waypoints * @returns {string} SVG path data */ @@ -17,64 +21,19 @@ export function curvedPath(points) { return `M ${points[0].x},${points[0].y} L ${points[1].x},${points[1].y}`; } + const n = points.length; let d = `M ${points[0].x},${points[0].y}`; - for (let i = 1; i < points.length - 1; i++) { - const prev = points[i - 1]; - const curr = points[i]; - const next = points[i + 1]; - - // Vectors from curr to prev and curr to next - const v1x = prev.x - curr.x; - const v1y = prev.y - curr.y; - const v2x = next.x - curr.x; - const v2y = next.y - curr.y; - - const len1 = Math.sqrt(v1x * v1x + v1y * v1y); - const len2 = Math.sqrt(v2x * v2x + v2y * v2y); - - if (len1 === 0 || len2 === 0) { - d += ` L ${curr.x},${curr.y}`; - continue; - } - - // Normalized vectors - const n1x = v1x / len1, n1y = v1y / len1; - const n2x = v2x / len2, n2y = v2y / len2; - - // Bisector direction (pointing "outward" from the angle) - const bx = n1x + n2x; - const by = n1y + n2y; - const bLen = Math.sqrt(bx * bx + by * by); - - if (bLen < 0.001) { - // Points are collinear, use straight line to midpoints - const mid1x = (prev.x + curr.x) / 2; - const mid1y = (prev.y + curr.y) / 2; - d += ` L ${mid1x},${mid1y} L ${curr.x},${curr.y}`; - continue; - } - - // Control point offset along bisector - const controlDist = Math.min(len1, len2) * 0.3; - const cx = curr.x + (bx / bLen) * controlDist; - const cy = curr.y + (by / bLen) * controlDist; - - // Midpoints for smooth transitions - const mid1x = (prev.x + curr.x) / 2; - const mid1y = (prev.y + curr.y) / 2; - - if (i === 1) { - d += ` Q ${cx},${cy} ${curr.x},${curr.y}`; - } else { - d += ` Q ${cx},${cy} ${curr.x},${curr.y}`; - } + for (let i = 1; i < n - 1; i++) { + const ctrl = points[i]; + // End each span at the midpoint to the next waypoint, except the final span + // which lands on the last point. + const end = (i === n - 2) + ? points[n - 1] + : { x: (points[i].x + points[i + 1].x) / 2, y: (points[i].y + points[i + 1].y) / 2 }; + d += ` Q ${ctrl.x},${ctrl.y} ${end.x},${end.y}`; } - // Last segment - const last = points[points.length - 1]; - d += ` L ${last.x},${last.y}`; - return d; } @@ -125,27 +84,21 @@ export function dribblingPath(points, scale = 1) { // Walk along path and create wave control points let d = `M ${points[0].x},${points[0].y}`; let walked = 0; - let segIdx = 0; - let segWalked = 0; let side = 1; for (let w = 0; w < numWaves; w++) { const halfTarget = walked + segLen / 2; const endTarget = walked + segLen; - // Find midpoint along path + // Midpoint of this wave, plus the tangent of whichever segment it lies on — + // so the perpendicular follows bends in the path instead of always using + // the first segment's direction. const mid = pointAlongPath(segments, halfTarget); const end = pointAlongPath(segments, endTarget); - // Direction at midpoint - const seg = segments[Math.min(segIdx, segments.length - 1)]; - const dx = seg.to.x - seg.from.x; - const dy = seg.to.y - seg.from.y; - const len = Math.sqrt(dx * dx + dy * dy) || 1; - - // Perpendicular - const px = -dy / len; - const py = dx / len; + // Perpendicular to the local tangent at the midpoint + const px = -mid.ty; + const py = mid.tx; const cx = mid.x + px * amplitude * side; const cy = mid.y + py * amplitude * side; @@ -159,20 +112,27 @@ export function dribblingPath(points, scale = 1) { } /** - * Find a point at a given distance along a series of segments. + * Find a point at a given distance along a series of segments, together with the + * unit tangent (tx, ty) of the segment it falls on. */ function pointAlongPath(segments, distance) { let remaining = distance; - for (const seg of segments) { + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]; const dx = seg.to.x - seg.from.x; const dy = seg.to.y - seg.from.y; const len = Math.sqrt(dx * dx + dy * dy); - if (remaining <= len || seg === segments[segments.length - 1]) { + const tx = len > 0 ? dx / len : 0; + const ty = len > 0 ? dy / len : 0; + if (remaining <= len || i === segments.length - 1) { const t = len > 0 ? Math.min(remaining / len, 1) : 0; - return { x: seg.from.x + dx * t, y: seg.from.y + dy * t }; + return { x: seg.from.x + dx * t, y: seg.from.y + dy * t, tx, ty }; } remaining -= len; } const last = segments[segments.length - 1]; - return { x: last.to.x, y: last.to.y }; + const dx = last.to.x - last.from.x; + const dy = last.to.y - last.from.y; + const len = Math.sqrt(dx * dx + dy * dy) || 1; + return { x: last.to.x, y: last.to.y, tx: dx / len, ty: dy / len }; } diff --git a/src/court/renderer.js b/src/court/renderer.js index 7545871..e211187 100644 --- a/src/court/renderer.js +++ b/src/court/renderer.js @@ -256,25 +256,20 @@ function renderPlayer(pos, transform, scheme) { svg += `${e.nr}`; } else if (e.type === 'defense') { const fill = e.color ? getColor(e.color, scheme) : scheme.defense_fill; - // Defense: FIBA-style symbol — horseshoe (open-top arc) + small inner circle - // Original FIBA path at scale 1 (width=40, height=24): outer arc + inner arc → closed horseshoe - // We scale to fit our radius r: scale = r*2/40 = r/20 - const s = r / 20; - const n = (v) => Math.round(v * 10) / 10; // round to 1 decimal const cx = svgPos.x, cy = svgPos.y; + const n = (v) => Math.round(v * 10) / 10; + // White background for contrast. svg += ``; - // Horseshoe: outer bezier arc + inner bezier arc (closed), centered at (cx, cy) - // Outer arc: M (cx-20s, cy+10s) C (cx-10s, cy-6s) (cx+10s, cy-6s) (cx+20s, cy+10s) - // Inner arc: C (cx+15s, cy-14s) (cx-15s, cy-14s) (cx-20s, cy+10s) Z - const ox1 = n(cx - 20*s), oy1 = n(cy + 10*s); - const ox2 = n(cx + 20*s), oy2 = n(cy + 10*s); - const oc1x = n(cx - 10*s), oc1y = n(cy - 6*s); - const oc2x = n(cx + 10*s), oc2y = n(cy - 6*s); - const ic1x = n(cx + 15*s), ic1y = n(cy - 14*s); - const ic2x = n(cx - 15*s), ic2y = n(cy - 14*s); - svg += ``; - // Inner white circle - svg += ``; + // FIBA defender: a chunky horseshoe (open at the bottom) drawn as a single + // thick stroked arc — no self-intersecting fill — with the number inside. + const Rc = r * 0.72; // centerline radius of the band + const sw = Math.max(2.5, r * 0.42); // band thickness + const gap = 52 * Math.PI / 180; // half-angle of the bottom opening + const aStart = -Math.PI / 2 - gap; // lower-left leg + const aEnd = -Math.PI / 2 + gap; // lower-right leg + const pt = (a) => `${n(cx + Rc * Math.cos(a))},${n(cy - Rc * Math.sin(a))}`; + // Major arc over the top: large-arc-flag=1, sweep-flag=1. + svg += ``; svg += `${e.nr}`; } else if (e.type === 'coach') { svg += ``; diff --git a/tests/curves.spec.js b/tests/curves.spec.js new file mode 100644 index 0000000..8c1410a --- /dev/null +++ b/tests/curves.spec.js @@ -0,0 +1,152 @@ +/** + * Geometric unit tests for the curve renderers (pure functions — no browser). + * + * Focus: the reported rendering bugs — + * - curved movement lines bulged toward the *inside* of a bend (and were + * half-straight) instead of passing smoothly through the waypoint; + * - dribbling waves used the first segment's direction for every wave, so + * after a bend the zig-zag pointed the wrong way. + */ + +import { test, expect } from '@playwright/test'; +import { curvedPath, straightPath, dribblingPath } from '../src/court/curves.js'; + +// ─── Tiny SVG-path sampler (supports the M / L / Q we emit) ───────────────────── + +/** Parse "M/L/Q" path data into a list of commands with numeric args. */ +function parsePath(d) { + const tokens = d.match(/[MLQ][^MLQ]*/g) || []; + return tokens.map(tok => { + const cmd = tok[0]; + const nums = (tok.slice(1).match(/-?\d+(?:\.\d+)?/g) || []).map(Number); + return { cmd, nums }; + }); +} + +/** Collect the anchor points the path passes through (M start + each L/Q end). */ +function pathAnchors(d) { + const pts = []; + for (const { cmd, nums } of parsePath(d)) { + if (cmd === 'M' || cmd === 'L') pts.push({ x: nums[0], y: nums[1] }); + else if (cmd === 'Q') pts.push({ x: nums[2], y: nums[3] }); + } + return pts; +} + +/** Quadratic control points (cx,cy) of every Q command. */ +function quadControls(d) { + return parsePath(d) + .filter(c => c.cmd === 'Q') + .map(c => ({ x: c.nums[0], y: c.nums[1] })); +} + +/** Signed side of point p relative to directed line a→b (>0 left, <0 right). */ +function sideOf(a, b, p) { + return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); +} + +/** Sample a single quadratic Bézier at parameter t. */ +function quadAt(p0, c, p1, t) { + const u = 1 - t; + return { + x: u * u * p0.x + 2 * u * t * c.x + t * t * p1.x, + y: u * u * p0.y + 2 * u * t * c.y + t * t * p1.y, + }; +} + +// ─── curvedPath ───────────────────────────────────────────────────────────────── + +test.describe('curvedPath', () => { + test('two points → a straight segment', () => { + const d = curvedPath([{ x: 0, y: 0 }, { x: 10, y: 0 }]); + expect(d).toBe('M 0,0 L 10,0'); + }); + + test('three points → a single quadratic with the waypoint as control', () => { + const p0 = { x: 0, y: 0 }, p1 = { x: 5, y: 4 }, p2 = { x: 10, y: 0 }; + const d = curvedPath([p0, p1, p2]); + const cmds = parsePath(d); + // Exactly: M p0 ; Q p1 p2 — no trailing straight "L" half. + expect(cmds.map(c => c.cmd)).toEqual(['M', 'Q']); + const q = cmds[1]; + expect(q.nums).toEqual([5, 4, 10, 0]); // control = waypoint, ends on p2 + }); + + test('curve bulges toward the waypoint (outward), not into the bend', () => { + const p0 = { x: 0, y: 0 }, p1 = { x: 5, y: 4 }, p2 = { x: 10, y: 0 }; + const d = curvedPath([p0, p1, p2]); + const ctrl = quadControls(d)[0]; + const apex = quadAt(p0, ctrl, p2, 0.5); + // Apex and the waypoint must be on the SAME side of the chord p0→p2. + const sApex = sideOf(p0, p2, apex); + const sWaypoint = sideOf(p0, p2, p1); + expect(Math.sign(sApex)).toBe(Math.sign(sWaypoint)); + // And it must actually bow out (not be ~collinear / straight). + expect(Math.abs(apex.y)).toBeGreaterThan(1); + }); + + test('endpoints are preserved for a multi-point path', () => { + const pts = [ + { x: 0, y: 0 }, { x: 4, y: 3 }, { x: 8, y: -2 }, { x: 12, y: 1 }, + ]; + const anchors = pathAnchors(curvedPath(pts)); + expect(anchors[0]).toEqual(pts[0]); // starts at P0 + expect(anchors[anchors.length - 1]).toEqual(pts[3]); // ends at Pn + }); + + test('interior waypoints are used as control points', () => { + const pts = [ + { x: 0, y: 0 }, { x: 4, y: 3 }, { x: 8, y: -2 }, { x: 12, y: 1 }, + ]; + const controls = quadControls(curvedPath(pts)); + expect(controls).toContainEqual({ x: 4, y: 3 }); + expect(controls).toContainEqual({ x: 8, y: -2 }); + }); +}); + +// ─── dribblingPath ────────────────────────────────────────────────────────────── + +test.describe('dribblingPath', () => { + test('starts at the first point and ends near the last', () => { + const pts = [{ x: 0, y: 0 }, { x: 20, y: 0 }]; + const anchors = pathAnchors(dribblingPath(pts, 1)); + expect(anchors[0]).toEqual({ x: 0, y: 0 }); + const end = anchors[anchors.length - 1]; + expect(end.x).toBeCloseTo(20, 5); + expect(end.y).toBeCloseTo(0, 5); + }); + + test('consecutive waves alternate sides of a straight path', () => { + const a = { x: 0, y: 0 }, b = { x: 30, y: 0 }; + const controls = quadControls(dribblingPath([a, b], 1)); + expect(controls.length).toBeGreaterThanOrEqual(2); + // On a horizontal path the wave controls sit above/below the x-axis in turn. + for (let i = 1; i < controls.length; i++) { + expect(Math.sign(controls[i].y)).toBe(-Math.sign(controls[i - 1].y)); + expect(Math.abs(controls[i].y)).toBeGreaterThan(0.1); + } + }); + + test('waves follow a bend: displacement is perpendicular to the local segment', () => { + // L-shaped path: first leg goes +x, second leg goes +y. + const a = { x: 0, y: 0 }, b = { x: 20, y: 0 }, c = { x: 20, y: 20 }; + const controls = quadControls(dribblingPath([a, b, c], 1)); + + // Controls whose midpoint lies on the vertical second leg must be displaced + // horizontally (perpendicular to +y). With the old bug they were displaced + // vertically (using the first segment's perpendicular) — i.e. x≈20. + const onSecondLeg = controls.filter(p => p.y > 1 && p.y < 19); + expect(onSecondLeg.length).toBeGreaterThan(0); + for (const p of onSecondLeg) { + // Perpendicular to the vertical leg ⇒ noticeable x offset from the line x=20. + expect(Math.abs(p.x - 20)).toBeGreaterThan(0.3); + } + }); +}); + +// ─── straightPath (sanity) ────────────────────────────────────────────────────── + +test('straightPath chains line segments through every point', () => { + const d = straightPath([{ x: 0, y: 0 }, { x: 1, y: 2 }, { x: 3, y: 4 }]); + expect(d).toBe('M 0,0 L 1,2 L 3,4'); +}); diff --git a/tests/renderer.spec.js b/tests/renderer.spec.js new file mode 100644 index 0000000..e5208f3 --- /dev/null +++ b/tests/renderer.spec.js @@ -0,0 +1,96 @@ +/** + * Renderer output tests (pure string generation — no browser). + * + * Guards the entity symbols, especially the FIBA defender, whose filled + * self-intersecting Bézier previously collapsed into two stray crescents + * instead of a horseshoe. The defender is now a single stroked arc. + */ + +import { test, expect } from '@playwright/test'; +import { renderOCF } from '../src/court/renderer.js'; + +function doc(entities, lines = []) { + return { + meta: { id: 't', title: 't' }, + court: { ruleset: 'fiba', type: 'half_court' }, + entities, + frames: [{ id: 'f1', lines }], + }; +} + +/** Pull every from an SVG string. */ +function paths(svg) { + return [...svg.matchAll(/]*\bd="([^"]*)"[^>]*>/g)].map(m => ({ + full: m[0], + d: m[1], + })); +} + +test.describe('defense symbol', () => { + const svg = renderOCF(doc([{ type: 'defense', nr: 5, x: 0, y: 7 }]), 0, 700, 760).svgContent; + + test('is drawn as a single open stroked arc, not a closed fill', () => { + // Identify the defender path by its fill color (#58001d), which court lines + // never use. It must be a stroked open arc — not the old self-closing blob. + const defenderPaths = paths(svg).filter(p => p.full.includes('58001d')); + expect(defenderPaths.length, 'expected one defender path').toBe(1); + const arc = defenderPaths[0]; + expect(arc.d).toContain('A'); // elliptical arc + expect(arc.d).not.toContain('Z'); // open at the bottom + expect(arc.d).not.toContain('C'); // not the old cubic-bezier blob + expect(arc.full).toContain('fill="none"'); // stroked, not filled + expect(arc.full).toContain('stroke="#58001d"'); + expect(arc.full).toMatch(/stroke-width="[\d.]+"/); + }); + + test('shows the defender number', () => { + expect(svg).toMatch(/>5<\/text>/); + }); + + test('does not draw the offense-style solid disc for a defender', () => { + // Offense draws a filled colored circle of radius 12; defense must not. + expect(svg).not.toMatch(/]*r="12"[^>]*fill="#58001d"/); + }); +}); + +test.describe('other entity symbols', () => { + test('offense is a filled numbered circle', () => { + const svg = renderOCF(doc([{ type: 'offense', nr: 7, x: 0, y: 7 }]), 0, 700, 760).svgContent; + expect(svg).toMatch(/]*r="12"[^>]*fill="#003366"/); + expect(svg).toMatch(/>7<\/text>/); + }); + + test('ball renders as a gradient ellipse', () => { + const svg = renderOCF(doc([{ type: 'ball', x: 0, y: 7 }]), 0, 700, 760).svgContent; + expect(svg).toMatch(/]*fill="url\(#ocf-ball-gradient\)"/); + }); + + test('coach renders as an outlined "C"', () => { + const svg = renderOCF(doc([{ type: 'coach', x: 0, y: 7 }]), 0, 700, 760).svgContent; + expect(svg).toMatch(/>C<\/text>/); + }); +}); + +test.describe('court + lines', () => { + test('court surface and rim are present', () => { + const svg = renderOCF(doc([]), 0, 700, 760).svgContent; + expect(svg).toContain('fill="#d4a06a"'); // court surface + expect(svg).toMatch(/stroke="#cc5500"/); // rim + }); + + test('a movement line ends with an arrow marker', () => { + const svg = renderOCF( + doc([], [{ type: 'movement', coords: [{ x: -4, y: 6 }, { x: 4, y: 6 }] }]), + 0, 700, 760, + ).svgContent; + expect(svg).toContain('marker-end="url(#ocf-arrow)"'); + }); + + test('a passing line is dashed', () => { + const svg = renderOCF( + doc([], [{ type: 'passing', coords: [{ x: -4, y: 6 }, { x: 4, y: 6 }] }]), + 0, 700, 760, + ).svgContent; + expect(svg).toMatch(/stroke-dasharray="6,4"/); + }); +});