Add configurable grid + snap to the layout system
Build image / build-and-push (push) Successful in 15s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s

Two independent grids, each with a size and a snap toggle:

- Garden grid (owner-set, in the garden settings form): drives the
  editor's visual grid and, when snapping is on, snaps objects on
  place/move and snaps their dimensions to whole grid steps on resize.
- Bed grid (per plantable object, in the bed Inspector): when snapping
  is on, plants snap to the bed's grid on place/move, and the grid is
  drawn inside the focused bed. Plant radius stays free.

Snapping defaults off on both, so existing gardens keep free placement.
Grid spacing shares the [1cm, 100m] range and defaults to 1m (garden) /
30cm (bed); 0 is treated as unset and re-defaulted.

Backend: migration 0005 adds grid_size_cm/snap_to_grid to gardens and
garden_objects, threaded through domain/store/service/api with service +
api round-trip tests. Frontend: new geometry snap helpers (unit-tested),
schema/type plumbing, canvas + overlay snapping, and the two forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-19 02:51:44 -04:00
co-authored by Claude Opus 4.8
parent 9968c06243
commit 3f4d5627d8
23 changed files with 553 additions and 64 deletions
+19 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
import { localToWorld, screenToWorld, worldToLocal, type Point } from '@/lib/geometry'
import { localToWorld, screenToWorld, snapPoint, snapValue, worldToLocal, type Point } from '@/lib/geometry'
import { useUpdateObject } from '@/lib/objects'
import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared'
import { useEditorStore } from './store'
@@ -27,10 +27,16 @@ export function SelectionOverlay({
object,
gardenId,
svgRef,
snap,
gridCm,
}: {
object: EditorObject
gardenId: number
svgRef: RefObject<SVGSVGElement | null>
// The garden grid: when snap is true, a move snaps the object's center to it and
// a resize snaps width/height to whole grid steps (the opposite corner stays put).
snap: boolean
gridCm: number
}) {
const setLiveObject = useEditorStore((s) => s.setLiveObject)
const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
@@ -112,7 +118,9 @@ export function SelectionOverlay({
e,
(ev) => {
const world = pointerWorld(ev)
return { ...base, xCm: base.xCm + (world.x - start.x), yCm: base.yCm + (world.y - start.y) }
const next: Point = { x: base.xCm + (world.x - start.x), y: base.yCm + (world.y - start.y) }
const c = snap ? snapPoint(next, gridCm) : next
return { ...base, xCm: c.x, yCm: c.y }
},
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
)
@@ -128,8 +136,15 @@ export function SelectionOverlay({
(ev) => {
const world = pointerWorld(ev)
const p = worldToLocal(world, center0, base.rotationDeg)
const newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
const newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
let newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
let newH = Math.max(MIN_OBJ_CM, Math.abs(p.y - oppositeLocal.y))
// Snap dimensions to whole grid steps (at least one cell). The opposite
// corner is the anchor, so it stays fixed while the dragged corner lands
// on a grid-multiple size.
if (snap) {
newW = Math.max(gridCm, snapValue(newW, gridCm))
newH = Math.max(gridCm, snapValue(newH, gridCm))
}
const draggedLocal: Point = { x: oppositeLocal.x + sx * newW, y: oppositeLocal.y + sy * newH }
const newCenterLocal: Point = {
x: (oppositeLocal.x + draggedLocal.x) / 2,