Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s

Closes #44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #45.
This commit is contained in:
2026-07-19 07:07:14 +00:00
committed by steve
parent 9968c06243
commit e74fb308c1
23 changed files with 581 additions and 88 deletions
+4
View File
@@ -19,6 +19,8 @@ export const gardenSchema = z.object({
heightCm: z.number(),
unitPref: unitPrefSchema,
notes: z.string(),
gridSizeCm: z.number(),
snapToGrid: z.boolean(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
@@ -55,6 +57,8 @@ export interface GardenInput {
heightCm: number
unitPref: UnitPref
notes: string
gridSizeCm: number
snapToGrid: boolean
}
export function useCreateGarden() {
+37
View File
@@ -3,6 +3,9 @@ import {
clampScale,
localToWorld,
screenToWorld,
snapLocalToBedGrid,
snapPoint,
snapValue,
worldToLocal,
worldToScreen,
zoomToFitRect,
@@ -91,3 +94,37 @@ describe('zoomToFitRect', () => {
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)
})
})
+31
View File
@@ -68,6 +68,37 @@ export function clampScale(scale: number, min: number, max: number): number {
return Math.min(max, Math.max(min, scale))
}
/** Snap a scalar to the nearest multiple of step (measured from 0). A step of 0
* or less (or a non-finite value) leaves v unchanged, so callers can pass the
* grid size straight through without guarding. */
export function snapValue(v: number, step: number): number {
if (!(step > 0)) return v
return Math.round(v / step) * step
}
/** Snap a world point to the nearest garden-grid intersection. The garden grid
* is anchored at the origin (0,0), which is exactly where the canvas draws it,
* so a snapped point always lands on a visible grid line. */
export function snapPoint(p: Point, step: number): Point {
return { x: snapValue(p.x, step), y: snapValue(p.y, step) }
}
/**
* Snap a point given in a bed's local frame (origin at the bed's center) to the
* bed's grid. The grid is anchored at the bed's top-left corner (-halfW,-halfH)
* — matching the grid lines the canvas draws inside the bed — and the result is
* clamped to the bed bounds so a plant never snaps outside it. step<=0 → only
* clamped, not snapped.
*/
export function snapLocalToBedGrid(p: Point, step: number, halfW: number, halfH: number): Point {
// Snap one axis to the grid anchored at -half (the bed's near edge), then clamp
// to [-half, half]. step<=0 skips the snap, so callers can pass step 0 to get a
// pure clamp — the non-snapping placement/move path — through the same helper.
const snapAxis = (v: number, half: number) =>
Math.max(-half, Math.min(half, step > 0 ? -half + Math.round((v + half) / step) * step : v))
return { x: snapAxis(p.x, halfW), y: snapAxis(p.y, halfH) }
}
/** Linear interpolation. */
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t
+10
View File
@@ -30,6 +30,8 @@ export const serverObjectSchema = z.object({
plantable: z.boolean(),
color: z.string().nullable().optional(),
props: z.string().nullable().optional(),
gridSizeCm: z.number(),
snapToGrid: z.boolean(),
notes: z.string(),
version: z.number(),
})
@@ -59,6 +61,8 @@ export function toEditorObject(o: ServerObject): EditorObject {
rotationDeg: o.rotationDeg,
zIndex: o.zIndex,
color: o.color ?? null,
gridSizeCm: o.gridSizeCm,
snapToGrid: o.snapToGrid,
notes: o.notes,
plantable: o.plantable,
version: o.version,
@@ -110,6 +114,8 @@ export interface ObjectCreate {
zIndex?: number
plantable?: boolean
color?: string | null
gridSizeCm?: number
snapToGrid?: boolean
}
export function useCreateObject(gardenId: number) {
@@ -138,6 +144,8 @@ export interface ObjectPatch {
zIndex?: number
plantable?: boolean
color?: string | null
gridSizeCm?: number
snapToGrid?: boolean
notes?: string
}
@@ -392,6 +400,8 @@ function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject {
...(patch.zIndex !== undefined ? { zIndex: patch.zIndex } : {}),
...(patch.plantable !== undefined ? { plantable: patch.plantable } : {}),
...(patch.color !== undefined ? { color: patch.color } : {}),
...(patch.gridSizeCm !== undefined ? { gridSizeCm: patch.gridSizeCm } : {}),
...(patch.snapToGrid !== undefined ? { snapToGrid: patch.snapToGrid } : {}),
...(patch.notes !== undefined ? { notes: patch.notes } : {}),
}
}