Units correctness: exact imperial entry, garden grid at dimension scale (#47) (#60)
Build image / build-and-push (push) Successful in 6s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #60.
This commit is contained in:
2026-07-21 04:50:49 +00:00
committed by steve
parent 1716362981
commit 8a12069118
7 changed files with 204 additions and 57 deletions
+32
View File
@@ -6,6 +6,7 @@ import {
snapLocalToBedGrid,
snapPoint,
snapValue,
visibleGridStepCm,
worldToLocal,
worldToScreen,
zoomToFitRect,
@@ -128,3 +129,34 @@ describe('grid snapping', () => {
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
})
})
+28
View File
@@ -99,6 +99,34 @@ export function snapLocalToBedGrid(p: Point, step: number, halfW: number, halfH:
return { x: snapAxis(p.x, halfW), y: snapAxis(p.y, halfH) }
}
// Multiples of the true grid tried, in ascending order within each decade, when
// the grid itself is too fine to draw legibly at the current zoom.
const GRID_COARSEN_MULTIPLES = [1, 2, 5]
const GRID_MAX_COARSEN_DECADES = 5 // up to 500,000× the true grid
/**
* The grid step (cm) to actually draw for a grid of `gridCm` at `scale` px/cm,
* so a cell is never smaller than `minCellPx`.
*
* Fading a too-fine grid out is what let a garden sit with snapping on and no
* grid drawn at all (#47). Instead, coarsen: draw 2×, 5×, 10×… the true grid, so
* every line drawn is still a real grid line and *something* aligned to the grid
* is always visible. Returns null only when even the coarsest multiple can't
* reach `minCellPx` — the caller should say so rather than draw nothing silently.
*/
export function visibleGridStepCm(gridCm: number, scale: number, minCellPx: number): number | null {
if (!(gridCm > 0) || !(scale > 0) || !(minCellPx > 0)) return null
let decade = 1
for (let d = 0; d <= GRID_MAX_COARSEN_DECADES; d++) {
for (const m of GRID_COARSEN_MULTIPLES) {
const step = gridCm * decade * m
if (step * scale >= minCellPx) return step
}
decade *= 10
}
return null
}
/** Linear interpolation. */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
+31 -7
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
cmFromDisplay,
cmFromFtIn,
cmFromMeters,
cmFromSpacing,
@@ -11,15 +12,37 @@ import {
} from './units'
describe('cm conversions', () => {
it('feet+inches → whole cm', () => {
expect(cmFromFtIn(4)).toBe(122) // 4ft = 121.92 → 122
expect(cmFromFtIn(8)).toBe(244)
expect(cmFromFtIn(4, 6)).toBe(137) // 4'6" = 137.16 → 137
// Every cm column is REAL, so entry is exact — no whole-cm quantization. A 1 ft
// grid is 30.48 cm, not 30, or the error accumulates across the garden (#47).
it('feet+inches → exact cm', () => {
expect(cmFromFtIn(1)).toBe(30.48)
expect(cmFromFtIn(4)).toBe(121.92)
expect(cmFromFtIn(8)).toBe(243.84)
expect(cmFromFtIn(4, 6)).toBe(137.16)
})
it('meters → whole cm', () => {
it('meters → exact cm', () => {
expect(cmFromMeters(10)).toBe(1000)
expect(cmFromMeters(1.22)).toBe(122)
expect(cmFromMeters(0.305)).toBe(30.5)
})
it('rounds away float dust, not precision', () => {
// 12 × 2.54 is 30.479999999999997 in IEEE-754; 1 ft must read as 30.48.
expect(cmFromFtIn(0, 12)).toBe(30.48)
expect(cmFromSpacing(12, 'imperial')).toBe(30.48)
expect(cmFromSpacing(1, 'imperial')).toBe(2.54)
})
it('round-trips display → cm → display at display precision', () => {
const imperial = [0.5, 1, 2.5, 4, 8, 12.5, 24]
for (const ft of imperial) {
expect(displayFromCm(cmFromDisplay(ft, 'imperial'), 'imperial')).toBe(ft)
}
const metric = [0.15, 1, 1.22, 3.05, 10, 24.5]
for (const m of metric) {
expect(displayFromCm(cmFromDisplay(m, 'metric'), 'metric')).toBe(m)
}
})
})
@@ -59,13 +82,14 @@ describe('plant spacing (small scale)', () => {
expect(formatSpacing(30, 'imperial')).toBe('12″')
})
it('parses an entered value back to whole cm', () => {
it('parses an entered value back to exact cm', () => {
expect(cmFromSpacing(15, 'metric')).toBe(15)
expect(cmFromSpacing(6, 'imperial')).toBe(15) // 6in = 15.24 → 15
expect(cmFromSpacing(6, 'imperial')).toBe(15.24)
})
it('prefills an input from cm', () => {
expect(spacingFromCm(15, 'metric')).toBe(15)
expect(spacingFromCm(30, 'imperial')).toBe(11.8) // 30/2.54 = 11.81 → 11.8
expect(spacingFromCm(30.48, 'imperial')).toBe(12) // an exactly-12in grid reads back as 12
})
})
+21 -7
View File
@@ -12,19 +12,32 @@ const INCHES_PER_FOOT = 12
export const MIN_DIMENSION_CM = 1
export const MAX_DIMENSION_CM = 10_000
// Below this, a garden-scale grid is fine enough to be worth questioning — it
// usually means someone reached for plant spacing, which belongs to the bed, not
// the garden. Soft: the form hints, it doesn't refuse.
export const MIN_GARDEN_GRID_CM = 10
/** Whether a centimeter dimension is within the server's accepted range. */
export function isValidDimensionCm(cm: number): boolean {
return Number.isFinite(cm) && cm >= MIN_DIMENSION_CM && cm <= MAX_DIMENSION_CM
}
/** Feet (+ optional inches) → whole centimeters. */
export function cmFromFtIn(feet: number, inches = 0): number {
return Math.round((feet * INCHES_PER_FOOT + inches) * CM_PER_INCH)
/** Round away IEEE-754 dust without rounding away precision: 12 × 2.54 is
* 30.479999999999997 in binary floating point, and the 1e-6 cm (10 nm) grain
* here is far below anything a garden cares about. Every cm column is REAL, so
* entry stays exact — this only stops 30.48 being stored as 30.479999999999997. */
function roundCm(cm: number): number {
return Math.round(cm * 1e6) / 1e6
}
/** Meters → whole centimeters. */
/** Feet (+ optional inches) → centimeters, exactly (1 ft → 30.48). */
export function cmFromFtIn(feet: number, inches = 0): number {
return roundCm((feet * INCHES_PER_FOOT + inches) * CM_PER_INCH)
}
/** Meters → centimeters, exactly. */
export function cmFromMeters(meters: number): number {
return Math.round(meters * CM_PER_METER)
return roundCm(meters * CM_PER_METER)
}
/** A value typed in the given unit (meters, or feet) → centimeters. */
@@ -79,9 +92,10 @@ export function formatSpacing(cm: number, unit: UnitPref): string {
return `${Math.round(cm)} cm`
}
/** A spacing value typed in the given unit (cm, or inches) → whole centimeters. */
/** A spacing value typed in the given unit (cm, or inches) → centimeters,
* exactly (12 in → 30.48). */
export function cmFromSpacing(value: number, unit: UnitPref): number {
return unit === 'imperial' ? Math.round(value * CM_PER_INCH) : Math.round(value)
return roundCm(unit === 'imperial' ? value * CM_PER_INCH : value)
}
/** Centimeters → a spacing number in the given unit, for prefilling an input.