Three compounding defects in the measurement layer (#47), measured against the live Home garden rather than estimated. Entry was quantized to whole centimeters even though every measurement column is REAL and the server only range-checks. A 12in grid stored 30cm — an 11.811in grid, short by 0.189in per cell, and because the error is systematic it accumulates: 4.61in of drift across a 24ft garden. cmFromFtIn / cmFromMeters / cmFromSpacing now convert exactly, rounding only at 1e-6 cm to clear IEEE-754 dust (12 x 2.54 is 30.479999999999997 in binary). Display-side rounding is untouched, so cm quantization still can't show through in a field. The garden grid was entered at spacing scale: "Grid size (in)" sat directly under "Width (ft)", two adjacent numeric fields at two different scales distinguished only by a suffix. Home ended up on a 3cm grid, almost certainly "1" typed meaning one foot. The garden grid is a layout concern, so it moves to dimension scale and reads "Garden grid (ft)"; the bed grid in the object inspector is a plant-spacing concern and deliberately stays at cm/in. A sub-10cm garden grid now draws a soft warning rather than being silently accepted. Snap could stay active while the grid stopped drawing: the canvas faded the grid out below 6px per cell, so Home ran with snapToGrid on, no grid drawn, and no snapping you could feel. Replaced the fade with coarsening — visibleGridStepCm picks the smallest multiple of the true grid (2x, 5x, 10x...) whose cells are legible, so every line drawn is still a real grid line and something aligned to the grid is always visible. When the drawn step isn't the true grid the canvas says so; a degenerate grid it can't draw at all says that instead of nothing. Closes #47 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
163 lines
5.6 KiB
TypeScript
163 lines
5.6 KiB
TypeScript
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
|
||
})
|
||
})
|