From 782f2394b239e04adbb9035eddf8736858b5b1d7 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Tue, 21 Jul 2026 00:38:06 -0400 Subject: [PATCH 1/2] Units correctness: exact imperial entry, garden grid at dimension scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- .../components/gardens/GardenFormModal.tsx | 75 ++++++++++--------- web/src/editor/GardenCanvas.tsx | 44 ++++++++--- web/src/lib/geometry.test.ts | 32 ++++++++ web/src/lib/geometry.ts | 28 +++++++ web/src/lib/units.test.ts | 38 ++++++++-- web/src/lib/units.ts | 28 +++++-- 6 files changed, 188 insertions(+), 57 deletions(-) diff --git a/web/src/components/gardens/GardenFormModal.tsx b/web/src/components/gardens/GardenFormModal.tsx index e1798f5..46a6c82 100644 --- a/web/src/components/gardens/GardenFormModal.tsx +++ b/web/src/components/gardens/GardenFormModal.tsx @@ -9,12 +9,11 @@ import { errorMessage } from '@/lib/api' import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens' import { cmFromDisplay, - cmFromSpacing, dimensionUnitLabel, displayFromCm, + formatCm, isValidDimensionCm, - spacingFromCm, - spacingUnitLabel, + MIN_GARDEN_GRID_CM, type UnitPref, } from '@/lib/units' @@ -47,7 +46,7 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric')) const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric')) const [gridSize, setGridSize] = useState(() => - String(spacingFromCm(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric')), + String(displayFromCm(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric')), ) const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false) const [notes, setNotes] = useState(garden?.notes ?? '') @@ -60,15 +59,12 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: const v = parseFloat(s) return Number.isFinite(v) ? String(displayFromCm(cmFromDisplay(v, unit), next)) : s } - // The grid is entered at spacing scale (cm/in), so it converts with the - // spacing helpers, not the dimension ones. - const convertGrid = (s: string) => { - const v = parseFloat(s) - return Number.isFinite(v) ? String(spacingFromCm(cmFromSpacing(v, unit), next)) : s - } setWidth(convert(width)) setHeight(convert(height)) - setGridSize(convertGrid(gridSize)) + // The garden grid is a layout concern, so it lives at the same scale as the + // garden's own dimensions — same helpers, same unit label. (The *bed* grid in + // the object inspector is a plant-spacing concern and stays at cm/in.) + setGridSize(convert(gridSize)) setUnit(next) } @@ -90,7 +86,7 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: setFormError('Width and height must be between 1 cm and 100 m.') return } - const gridSizeCm = cmFromSpacing(parseFloat(gridSize), unit) + const gridSizeCm = cmFromDisplay(parseFloat(gridSize), unit) if (!isValidDimensionCm(gridSizeCm)) { setFormError('Grid size must be between 1 cm and 100 m.') return @@ -123,7 +119,7 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: setUnit(current.unitPref) setWidth(dimString(current.widthCm, current.unitPref)) setHeight(dimString(current.heightCm, current.unitPref)) - setGridSize(String(spacingFromCm(current.gridSizeCm, current.unitPref))) + setGridSize(String(displayFromCm(current.gridSizeCm, current.unitPref))) setSnapToGrid(current.snapToGrid) setNotes(current.notes) setConflict('This garden changed elsewhere. The latest values are shown — review and save again.') @@ -134,6 +130,10 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: } const unitLabel = dimensionUnitLabel(unit) + // Soft floor: warn, don't refuse. A sub-4″ garden grid is almost always "1" + // typed meaning one foot, but it's a legitimate choice for a tiny garden. + const gridEntered = cmFromDisplay(parseFloat(gridSize), unit) + const gridTooFine = Number.isFinite(gridEntered) && gridEntered > 0 && gridEntered < MIN_GARDEN_GRID_CM return ( @@ -175,28 +175,35 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: /> -
-
- setGridSize(e.target.value)} - /> +
+
+
+ setGridSize(e.target.value)} + /> +
+
- + {gridTooFine && ( +

+ That's a {formatCm(gridEntered, unit)} grid for the whole garden — did you mean {unitLabel}? +

+ )}