diff --git a/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.js b/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.js index a6578f0..3b58b6c 100644 --- a/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.js +++ b/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.js @@ -1,6 +1,14 @@ +import { + compose, + translate, + rotate, + shear, + applyToPolygon, +} from '$lib/geom-engine/transforms.js'; + /** * Geometry helpers for the Pythagoras dissection-shear lesson. - * All coordinates are in SVG viewBox units (0 0 400 400). + * All coordinates are in SVG viewBox units. * * Triangle layout (legs axis-aligned): * A — top apex, x = R.x, y = fixed top @@ -39,9 +47,10 @@ export function squareB(R, H) { * @returns {Poly} */ export function squareC(A, H, a, b, c) { - // Outward normal unit vector (rotated 90° clockwise from AH direction): - // AH direction = (b/c, a/c) → normal = (a/c, -b/c) - const nx = a; // not yet divided by c; we scale by c below so net offset = (a, -b) + // Offset to the far side of the square: the AH direction (b, a) turned a + // quarter turn, giving (a, -b). Its length is hypot(a, b) = c, so the far + // edge sits exactly one side-length away. + const nx = a; const ny = -b; return [ A, @@ -99,16 +108,78 @@ export function shearBTarget(F, H, a, b, c) { } /** - * Linearly interpolate between two polygons of equal length. - * Returns a new polygon with each vertex lerped. - * @param {Poly} from @param {Poly} to @param {number} t — 0..1 + * Progress of stage `i` (0-based) when the whole morph runs over t ∈ [0,1]. + * @param {number} t @param {number} i @returns {number} + */ +function stage(t, i) { + return Math.max(0, Math.min(1, t * 3 - i)); +} + +/** + * Shear parallel to the hypotenuse-square normal, holding the line through + * `origin` in that direction fixed. Points move by `lambda` times their + * offset along AH, which is what slides a vertex onto the altitude foot. + * @param {Pt} origin @param {number} a @param {number} b @param {number} lambda + * @returns {import('$lib/geom-engine/transforms.js').Mat3} + */ +function shearAlongNormal(origin, a, b, lambda) { + const alpha = Math.atan2(a, b); // direction of AH + return compose( + translate(-origin.x, -origin.y), + rotate(-alpha), + shear(0, -lambda), + rotate(alpha), + translate(origin.x, origin.y) + ); +} + +/** + * Area-preserving morph of the square on leg `a` onto the rectangle it equals + * inside the hypotenuse square. Runs in three stages, each of determinant 1, + * so the area is exactly a² at every `t`: + * + * 1. shear parallel to AH's horizontal leg, sliding R onto H + * 2. quarter turn about A + * 3. shear parallel to the hypotenuse-square normal, sliding a vertex onto F + * + * @param {Pt} A @param {Pt} R @param {Pt} H + * @param {number} a @param {number} b @param {number} t 0..1 * @returns {Poly} */ -export function lerpPoly(from, to, t) { - return from.map((p, i) => ({ - x: p.x + (to[i].x - p.x) * t, - y: p.y + (to[i].y - p.y) * t, - })); +export function morphSquareA(A, R, H, a, b, t) { + const m = compose( + // stage 1 — horizontal shear about the line y = A.y + translate(0, -A.y), + shear((b * stage(t, 0)) / a, 0), + translate(0, A.y), + // stage 2 — quarter turn about A + rotate((-Math.PI / 2) * stage(t, 1), A), + // stage 3 — shear onto the altitude foot + shearAlongNormal(A, a, b, (b / a) * stage(t, 2)) + ); + return applyToPolygon(m, squareA(A, R)); +} + +/** + * Area-preserving morph of the square on leg `b` onto its rectangle, mirroring + * `morphSquareA` about the hypotenuse. Area is exactly b² at every `t`. + * + * @param {Pt} R @param {Pt} H + * @param {number} a @param {number} b @param {number} t 0..1 + * @returns {Poly} + */ +export function morphSquareB(R, H, a, b, t) { + const m = compose( + // stage 1 — vertical shear about the line x = H.x + translate(-H.x, 0), + shear(0, (a * stage(t, 0)) / b), + translate(H.x, 0), + // stage 2 — quarter turn about H, the other way round + rotate((Math.PI / 2) * stage(t, 1), H), + // stage 3 — shear onto the altitude foot + shearAlongNormal(H, a, b, (-a / b) * stage(t, 2)) + ); + return applyToPolygon(m, squareB(R, H)); } /** diff --git a/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.test.js b/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.test.js new file mode 100644 index 0000000..711ce0b --- /dev/null +++ b/src/lib/lessons/dinh-ly-pythagoras/geom-helpers.test.js @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import { + squareA, + squareB, + squareC, + altitudeFoot, + shearATarget, + shearBTarget, + morphSquareA, + morphSquareB, +} from './geom-helpers.js'; + +// Layout constants mirrored from the lesson page. +const VIEW = 520; +const APEX_Y = 200; +const FOOT_X = 320; +const LEG_MIN = 40; +const LEG_MAX = 150; + +/** + * Shoelace area of a closed polygon. + * @param {{x: number, y: number}[]} pts @returns {number} + */ +function area(pts) { + let sum = 0; + for (let i = 0; i < pts.length; i++) { + const q = pts[(i + 1) % pts.length]; + sum += pts[i].x * q.y - q.x * pts[i].y; + } + return Math.abs(sum) / 2; +} + +/** + * Build the whole figure for a given right-angle vertex. + * @param {{x: number, y: number}} R + */ +function figure(R) { + const A = { x: R.x, y: APEX_Y }; + const H = { x: FOOT_X, y: R.y }; + const a = R.y - APEX_Y; + const b = FOOT_X - R.x; + const c = Math.hypot(a, b); + return { A, H, a, b, c, F: altitudeFoot(A, H, a, c) }; +} + +/** + * Same set of vertices, allowing for rotation and reversal of the cycle. + * @param {{x: number, y: number}[]} p @param {{x: number, y: number}[]} q + */ +function sameVertices(p, q) { + const key = (/** @type {{x: number, y: number}[]} */ pts) => + pts + .map((v) => `${v.x.toFixed(6)},${v.y.toFixed(6)}`) + .sort() + .join('|'); + return key(p) === key(q); +} + +const CORNERS = [ + { x: FOOT_X - LEG_MAX, y: APEX_Y + LEG_MAX }, + { x: FOOT_X - LEG_MIN, y: APEX_Y + LEG_MAX }, + { x: FOOT_X - LEG_MAX, y: APEX_Y + LEG_MIN }, + { x: FOOT_X - LEG_MIN, y: APEX_Y + LEG_MIN }, + { x: 200, y: 320 }, +]; + +describe('morphSquareA / morphSquareB — area is preserved', () => { + it('square a keeps area a² at every step of the morph', () => { + for (const R of CORNERS) { + const { A, H, a, b } = figure(R); + for (let t = 0; t <= 1.00001; t += 0.02) { + expect(area(morphSquareA(A, R, H, a, b, t))).toBeCloseTo(a * a, 6); + } + } + }); + + it('square b keeps area b² at every step of the morph', () => { + for (const R of CORNERS) { + const { H, a, b } = figure(R); + for (let t = 0; t <= 1.00001; t += 0.02) { + expect(area(morphSquareB(R, H, a, b, t))).toBeCloseTo(b * b, 6); + } + } + }); + + it('starts on the leg squares and ends on the hypotenuse rectangles', () => { + for (const R of CORNERS) { + const { A, H, a, b, c, F } = figure(R); + + expect(sameVertices(morphSquareA(A, R, H, a, b, 0), squareA(A, R))).toBe(true); + expect(sameVertices(morphSquareB(R, H, a, b, 0), squareB(R, H))).toBe(true); + + expect(sameVertices(morphSquareA(A, R, H, a, b, 1), shearATarget(A, F, a, b, c))).toBe(true); + expect(sameVertices(morphSquareB(R, H, a, b, 1), shearBTarget(F, H, a, b, c))).toBe(true); + } + }); + + it('the two rectangles together fill the square on the hypotenuse', () => { + for (const R of CORNERS) { + const { A, H, a, b, c, F } = figure(R); + const total = area(shearATarget(A, F, a, b, c)) + area(shearBTarget(F, H, a, b, c)); + expect(total).toBeCloseTo(area(squareC(A, H, a, b, c)), 6); + expect(total).toBeCloseTo(a * a + b * b, 6); + } + }); +}); + +describe('layout stays on canvas', () => { + it('every polygon fits the viewBox for any reachable R, throughout the morph', () => { + for (const R of CORNERS) { + const { A, H, a, b, c, F } = figure(R); + const polys = [ + squareA(A, R), + squareB(R, H), + squareC(A, H, a, b, c), + shearATarget(A, F, a, b, c), + shearBTarget(F, H, a, b, c), + ]; + for (let t = 0; t <= 1.00001; t += 0.05) { + polys.push(morphSquareA(A, R, H, a, b, t), morphSquareB(R, H, a, b, t)); + } + for (const poly of polys) { + for (const p of poly) { + expect(p.x).toBeGreaterThanOrEqual(0); + expect(p.x).toBeLessThanOrEqual(VIEW); + expect(p.y).toBeGreaterThanOrEqual(0); + expect(p.y).toBeLessThanOrEqual(VIEW); + } + } + } + }); +}); diff --git a/src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte b/src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte index b04f448..0da26e7 100644 --- a/src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte +++ b/src/routes/hinh-hoc/dinh-ly-pythagoras/+page.svelte @@ -8,15 +8,19 @@ import { cubicOut } from 'svelte/easing'; import { squareA, squareB, squareC, altitudeFoot, - shearATarget, shearBTarget, lerpPoly, polyPoints, + shearATarget, shearBTarget, morphSquareA, morphSquareB, polyPoints, } from '$lib/lessons/dinh-ly-pythagoras/geom-helpers.js'; const copy = t(); - const VIEW = 400; - // Layout: apex fixed at y=100, foot fixed at x=320; right-angle vertex R is draggable. - // Min leg 40px prevents degenerate triangles and keeps squares visible. - const APEX_Y = 100, FOOT_X = 320; - const INIT = { x: 160, y: 280 }; + // The viewBox has to hold the square on the hypotenuse too, which reaches + // FOOT_X + a to the right and APEX_Y - b above. With both legs capped at + // LEG_MAX the drawing stays inside [0, VIEW] for every reachable R. + const VIEW = 520; + // Layout: apex fixed at y=200, foot fixed at x=320; right-angle vertex R is draggable. + const APEX_Y = 200, FOOT_X = 320; + // Min leg keeps the triangle non-degenerate; max keeps every square on canvas. + const LEG_MIN = 40, LEG_MAX = 150; + const INIT = { x: 200, y: 320 }; const reducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -44,15 +48,15 @@ // Phase A: shear-a moves in first half of tween; phase B: shear-b in second half const tA = $derived(Math.min(1, $tp * 2)); const tB = $derived(Math.max(0, $tp * 2 - 1)); - const shearA = $derived(lerpPoly(sqA, tgtA, tA)); - const shearB = $derived(lerpPoly(sqB, tgtB, tB)); + const shearA = $derived(morphSquareA(A, R, H, a, b, tA)); + const shearB = $derived(morphSquareB(R, H, a, b, tB)); const texSides = $derived(`a=${a.toFixed(1)},\\;b=${b.toFixed(1)},\\;c=${c.toFixed(1)}`); const texNums = $derived(`${(a*a).toFixed(1)}+${(b*b).toFixed(1)}=${(c*c).toFixed(1)}`); /** @param {{ x: number; y: number }} p */ const clampR = (p) => ({ - x: Math.max(60, Math.min(FOOT_X - 40, p.x)), - y: Math.max(APEX_Y + 40, Math.min(VIEW - 60, p.y)), + x: Math.max(FOOT_X - LEG_MAX, Math.min(FOOT_X - LEG_MIN, p.x)), + y: Math.max(APEX_Y + LEG_MIN, Math.min(APEX_Y + LEG_MAX, p.y)), }); const dragOpts = $derived({ point: R, svg: () => svgEl ?? null, @@ -79,9 +83,22 @@ } if (import.meta.env.DEV) { + /** Shoelace area of a closed polygon. */ + const polyArea = (/** @type {{x:number,y:number}[]} */ pts) => { + let sum = 0; + for (let i = 0; i < pts.length; i++) { + const q = pts[(i + 1) % pts.length]; + sum += pts[i].x * q.y - q.x * pts[i].y; + } + return Math.abs(sum) / 2; + }; $effect(() => { - if (phase === 'proven' && Math.abs(a*a + b*b - c*c) > 0.01) - console.error(`[pythagoras] area mismatch: a²+b²=${a*a+b*b} c²=${c*c}`); + // The morph is built from shears and rotations, so each polygon must + // keep its area at every frame. This is what silently broke before. + if (Math.abs(polyArea(shearA) - a * a) > 0.01) + console.error(`[pythagoras] square-a area drifted: ${polyArea(shearA)} vs ${a * a}`); + if (Math.abs(polyArea(shearB) - b * b) > 0.01) + console.error(`[pythagoras] square-b area drifted: ${polyArea(shearB)} vs ${b * b}`); }); }