Files
pansy/web/src/lib/geometry.test.ts
T
steveandClaude Opus 4.8 782f2394b2
Build image / build-and-push (push) Successful in 16s
Gadfly review (reusable) / review (pull_request) Successful in 5m53s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m53s
Units correctness: exact imperial entry, garden grid at dimension scale
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
2026-07-21 00:38:06 -04:00

163 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
})
})