import { describe, expect, it } from 'vitest' import { clampScale, localToWorld, screenToWorld, snapLocalToBedGrid, snapPoint, snapValue, visibleGridStepCm, worldToLocal, worldToScreen, zoomToFitRect, zoomViewportAt, type Viewport, } from './geometry' const vp: Viewport = { tx: 100, ty: 50, scale: 2 } describe('world/screen transforms', () => { it('round-trips a point', () => { const p = { x: 37.5, y: -12.25 } const back = screenToWorld(worldToScreen(p, vp), vp) expect(back.x).toBeCloseTo(p.x, 9) expect(back.y).toBeCloseTo(p.y, 9) }) it('applies scale then translate', () => { expect(worldToScreen({ x: 10, y: 10 }, vp)).toEqual({ x: 120, y: 70 }) }) }) describe('object-local/world transforms', () => { const center = { x: 500, y: 300 } it('round-trips through any rotation', () => { for (const deg of [0, 30, 90, 180, 270, 45, -60, 123.4]) { const local = { x: 40, y: -25 } const back = worldToLocal(localToWorld(local, center, deg), center, deg) expect(back.x).toBeCloseTo(local.x, 6) expect(back.y).toBeCloseTo(local.y, 6) } }) it('rotates 90° clockwise on screen (y down)', () => { // A point 10cm to the object's right, rotated 90°, lands 10cm below center. const w = localToWorld({ x: 10, y: 0 }, center, 90) expect(w.x).toBeCloseTo(center.x, 6) expect(w.y).toBeCloseTo(center.y + 10, 6) }) it('local origin maps to the object center', () => { expect(localToWorld({ x: 0, y: 0 }, center, 37)).toEqual(center) }) }) describe('clampScale', () => { it('bounds the value', () => { expect(clampScale(0.001, 0.05, 20)).toBe(0.05) expect(clampScale(100, 0.05, 20)).toBe(20) expect(clampScale(3, 0.05, 20)).toBe(3) }) }) describe('zoomViewportAt', () => { it('keeps the point under the cursor stationary', () => { const cursor = { x: 640, y: 360 } const before = screenToWorld(cursor, vp) const next = zoomViewportAt(vp, cursor, vp.scale * 1.25, 0.05, 20) const after = screenToWorld(cursor, next) expect(after.x).toBeCloseTo(before.x, 6) expect(after.y).toBeCloseTo(before.y, 6) expect(next.scale).toBeCloseTo(2.5, 9) }) it('clamps the scale', () => { expect(zoomViewportAt(vp, { x: 0, y: 0 }, 1000, 0.05, 20).scale).toBe(20) }) }) describe('zoomToFitRect', () => { it('centers the rect and fits it within padding', () => { const viewport = { w: 800, h: 600 } const rect = { x: 0, y: 0, w: 1000, h: 1000 } const fit = zoomToFitRect(rect, viewport, 20, 0.05, 20) // Limiting dimension is height: (600-40)/1000 = 0.56. expect(fit.scale).toBeCloseTo(0.56, 9) // The rect's center (500,500) should map to the viewport center (400,300). const c = worldToScreen({ x: 500, y: 500 }, fit) expect(c.x).toBeCloseTo(400, 6) expect(c.y).toBeCloseTo(300, 6) }) it('respects the min/max scale clamp', () => { const fit = zoomToFitRect({ x: 0, y: 0, w: 1, h: 1 }, { w: 800, h: 600 }, 0, 0.05, 20) expect(fit.scale).toBe(20) }) }) describe('grid snapping', () => { it('snaps a scalar to the nearest multiple of the step', () => { expect(snapValue(37, 30)).toBe(30) expect(snapValue(46, 30)).toBe(60) expect(snapValue(-46, 30)).toBe(-60) expect(snapValue(15, 30)).toBe(30) // .5 rounds up }) it('leaves a scalar unchanged for a non-positive step', () => { expect(snapValue(37, 0)).toBe(37) expect(snapValue(37, -5)).toBe(37) expect(snapValue(37, NaN)).toBe(37) }) it('snaps a world point to the origin-anchored garden grid', () => { expect(snapPoint({ x: 37, y: -46 }, 30)).toEqual({ x: 30, y: -60 }) }) it('snaps a local point to the bed grid anchored at the top-left corner', () => { // A 120×80 bed (halfW=60, halfH=40), 30cm grid: lines at x=-60,-30,0,30,60 // and y=-40,-10,20 (from the corner, then +30 while ≤ halfH). expect(snapLocalToBedGrid({ x: 5, y: 2 }, 30, 60, 40)).toEqual({ x: 0, y: -10 }) expect(snapLocalToBedGrid({ x: 22, y: 18 }, 30, 60, 40)).toEqual({ x: 30, y: 20 }) }) it('clamps a snapped bed point back inside the bed bounds', () => { // Near the far corner, rounding would land at x=90 (> halfW=60); clamp to 60. const p = snapLocalToBedGrid({ x: 100, y: 100 }, 30, 60, 40) expect(p.x).toBeLessThanOrEqual(60) expect(p.y).toBeLessThanOrEqual(40) expect(p.x).toBeGreaterThanOrEqual(-60) }) }) describe('visibleGridStepCm', () => { it('draws the true grid when its cells are already legible', () => { expect(visibleGridStepCm(30.48, 1, 6)).toBe(30.48) expect(visibleGridStepCm(10, 0.6, 6)).toBe(10) // exactly at the minimum }) it('coarsens to a multiple rather than fading out', () => { // The #47 failure: a 3cm grid at 0.84 px/cm is 2.5px per cell. Instead of // drawing nothing while snapping stays on, draw every 5th line (12.6px). expect(visibleGridStepCm(3, 0.84, 6)).toBe(15) expect(visibleGridStepCm(1, 0.84, 6)).toBe(10) }) it('only ever returns a whole multiple of the true grid', () => { for (const scale of [0.05, 0.3, 0.84, 1, 4]) { const step = visibleGridStepCm(7, scale, 6) expect(step).not.toBeNull() expect(Math.round(step! / 7) * 7).toBeCloseTo(step!, 9) expect(step! * scale).toBeGreaterThanOrEqual(6) } }) it('reports null for a degenerate grid rather than drawing nothing silently', () => { expect(visibleGridStepCm(0, 1, 6)).toBeNull() expect(visibleGridStepCm(-5, 1, 6)).toBeNull() expect(visibleGridStepCm(NaN, 1, 6)).toBeNull() expect(visibleGridStepCm(30, 0, 6)).toBeNull() expect(visibleGridStepCm(1e-9, 1, 6)).toBeNull() // beyond the coarsening cap }) })