import { describe, expect, it } from 'vitest'; import { apply, type Mat } from '../../src/core/model/matrix'; import { contentFrame, knobAt, knobMode, resized, scaled, sizeGripAt, swept, turned, } from '../../src/editor/sizeGizmo'; // A 100 × 40 box centred on (0, 0), corners as `boxCorners` lists them. const BOX = [ { x: -50, y: -20 }, { x: 50, y: -20 }, { x: 50, y: 20 }, { x: -50, y: 20 }, ]; const ALL = { sizable: true, corners: true }; describe('size gizmo (#223)', () => { it('puts the knob out from the middle of the top edge, turned with the box', () => { expect(knobAt(BOX, 26)).toEqual({ base: { x: 0, y: -20 }, knob: { x: 0, y: -46 } }); // Turned a quarter clockwise: the top edge is now the right-hand one. const turnedBox = [ { x: 20, y: -50 }, { x: 20, y: 50 }, { x: -20, y: 50 }, { x: -20, y: -50 }, ]; const { knob } = knobAt(turnedBox, 26); expect(knob.x).toBeCloseTo(46); expect(knob.y).toBeCloseTo(0); }); it('finds the knob first, then a corner, then an edge', () => { const knob = { x: 0, y: -46 }; expect(sizeGripAt(BOX, knob, { x: 1, y: -45 }, 7, ALL)).toEqual({ kind: 'knob' }); expect(sizeGripAt(BOX, knob, { x: 48, y: 18 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 1, sy: 1 } }); expect(sizeGripAt(BOX, knob, { x: 10, y: 22 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } }); expect(sizeGripAt(BOX, knob, { x: -53, y: 0 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: -1, sy: 0 }, }); expect(sizeGripAt(BOX, knob, { x: 0, y: 0 }, 7, ALL)).toBeNull(); }); it('reaches only half as far into the box, so a small sprite can still be grabbed off its middle', () => { const knob = { x: 0, y: -46 }; expect(sizeGripAt(BOX, knob, { x: 10, y: 26 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } }); expect(sizeGripAt(BOX, knob, { x: 10, y: 17 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 0, sy: 1 } }); expect(sizeGripAt(BOX, knob, { x: 10, y: 14 }, 7, ALL)).toBeNull(); expect(sizeGripAt(BOX, knob, { x: 45, y: 15 }, 7, ALL)).toBeNull(); expect(sizeGripAt(BOX, knob, { x: 48, y: 18 }, 7, ALL)).toEqual({ kind: 'size', side: { sx: 1, sy: 1 } }); }); it('leaves the corners to the edges on a circle, and a polygon only its knob', () => { const knob = { x: 0, y: -46 }; expect(sizeGripAt(BOX, knob, { x: 49, y: 17 }, 7, { sizable: true, corners: false })).toEqual({ kind: 'size', side: { sx: 1, sy: 0 }, }); expect(sizeGripAt(BOX, knob, { x: 50, y: 0 }, 7, { sizable: false, corners: true })).toBeNull(); expect(sizeGripAt(BOX, knob, { x: 0, y: -46 }, 7, { sizable: false, corners: true })).toEqual({ kind: 'knob', }); }); it('sizes from the dragged side and keeps the opposite one where it was', () => { // Right edge from 50 out to 80: 30 wider, the centre 15 to the right, the left edge stays at -50. expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: 80, y: 3 }, false)).toEqual({ w: 130, h: 40, shift: { x: 15, y: 0 }, }); // Top left corner in by 10 and 5. expect(resized({ w: 100, h: 40 }, { sx: -1, sy: -1 }, { x: -40, y: -15 }, false)).toEqual({ w: 90, h: 35, shift: { x: 5, y: 2.5 }, }); }); it('rounds to whole units, never below one, and keeps a circle round', () => { expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: 60.4, y: 0 }, false).w).toBe(110); expect(resized({ w: 100, h: 40 }, { sx: 1, sy: 0 }, { x: -500, y: 0 }, false).w).toBe(1); const circle = resized({ w: 40, h: 40 }, { sx: 0, sy: 1 }, { x: 0, y: 40 }, true); expect(circle).toEqual({ w: 60, h: 60, shift: { x: 0, y: 10 } }); }); it('decides once between turning and scaling by the way the knob leaves the press', () => { const centre = { x: 0, y: 0 }; const press = { x: 0, y: -46 }; expect(knobMode(centre, press, { x: 2, y: -46 }, 5)).toBeNull(); expect(knobMode(centre, press, { x: 12, y: -44 }, 5)).toBe('turn'); expect(knobMode(centre, press, { x: 3, y: -60 }, 5)).toBe('scale'); expect(knobMode(centre, press, { x: -2, y: -30 }, 5)).toBe('scale'); }); it('turns by the angle swept round the centre, the other way under a mirror, snapped with Shift', () => { const centre = { x: 0, y: 0 }; const press = { x: 0, y: -46 }; const quarter = swept(centre, press, { x: 46, y: 0 }); expect(quarter).toBeCloseTo(90); expect(turned(10, quarter, 1)).toBeCloseTo(100); expect(turned(10, quarter, -1)).toBeCloseTo(-80); expect(turned(0, swept(centre, press, { x: 46, y: -40 }), 1, 15)).toBe(45); }); it('adds the sweeps of a drag up, so a turn past the half circle goes on instead of jumping back', () => { const centre = { x: 0, y: 0 }; const path = [ { x: 0, y: -46 }, { x: 46, y: 0 }, { x: 0, y: 46 }, { x: -46, y: 1 }, ]; let sweep = 0; for (let i = 1; i < path.length; i++) sweep += swept(centre, path[i - 1]!, path[i]!); expect(turned(0, sweep, 1)).toBeCloseTo(268.8, 1); }); it('scales by how much farther from the centre the knob is pulled', () => { const centre = { x: 0, y: 0 }; expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: -80 })).toBe(2); expect(scaled(1.5, centre, { x: 0, y: -40 }, { x: 0, y: -20 })).toBe(0.75); expect(scaled(1, centre, { x: 0, y: -40 }, { x: 0, y: 0 })).toBe(0.01); }); }); describe('the frame round a container (#242)', () => { // Turned a quarter to the right and twice the size, standing at (100, 50). const m: Mat = { a: 0, b: 2, c: -2, d: 0, tx: 100, ty: 50 }; const own = (x: number, y: number) => apply(m, { x, y }); it("boxes the content in the container's own axes, so it turns and grows with it", () => { const content = [own(10, -20), own(30, -20), own(30, 0), own(10, 0), own(20, -10)]; const frame = contentFrame(m, content)!; const expected = [own(10, -20), own(30, -20), own(30, 0), own(10, 0)]; frame.forEach((corner, i) => { expect(corner.x).toBeCloseTo(expected[i]!.x); expect(corner.y).toBeCloseTo(expected[i]!.y); }); // The top edge in its own axes, where the knob stands, is on the world's right side now. expect(knobAt(frame, 26).knob.x).toBeGreaterThan(Math.max(...frame.map((c) => c.x))); }); it('has none without content or at no size', () => { expect(contentFrame(m, [])).toBeNull(); expect(contentFrame({ ...m, b: 0, c: 0 }, [own(0, 0)])).toBeNull(); }); });