From 27ec89fe89f1d81e2c631539606c6e78fc888a07 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Tue, 15 Sep 2026 10:06:57 +0700 Subject: [PATCH] fix(dinh-ly-pythagoras): preserve area through the shear proof The animation interpolated each vertex straight from the leg square to its target rectangle, which is not area preserving: the square collapsed to about a ninth of its area midway, so a lesson about shear preserving area showed the opposite. The morph is now a composition of shears and a quarter turn built on geom-engine/transforms.js, each of determinant 1, so the area is exactly a squared and b squared at every frame. The square on the hypotenuse also reached outside the viewBox. The canvas is larger and the legs are capped so every polygon stays on screen for any position of the draggable vertex. --- .../dinh-ly-pythagoras/geom-helpers.js | 95 +++++++++++-- .../dinh-ly-pythagoras/geom-helpers.test.js | 132 ++++++++++++++++++ .../hinh-hoc/dinh-ly-pythagoras/+page.svelte | 41 ++++-- 3 files changed, 244 insertions(+), 24 deletions(-) create mode 100644 src/lib/lessons/dinh-ly-pythagoras/geom-helpers.test.js 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}`); }); }