Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s
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:
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user