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
+25 -3
View File
@@ -10,9 +10,10 @@ import (
)
const (
maxObjectNameLen = 200
maxObjectNotesLen = 10_000
maxObjectPropsLen = 20_000
maxObjectNameLen = 200
maxObjectNotesLen = 10_000
maxObjectPropsLen = 20_000
defaultObjectGridCM = 30 // ~1 ft, a practical bed cell for plant snapping
)
// objectKinds maps each valid garden_objects.kind to its traits. plantable is
@@ -43,6 +44,8 @@ type ObjectInput struct {
Plantable *bool
Color *string
Props *string
GridSizeCM float64
SnapToGrid bool
Notes string
}
@@ -65,6 +68,8 @@ type ObjectPatch struct {
Color *string
SetProps bool
Props *string
GridSizeCM *float64
SnapToGrid *bool
Notes *string
}
@@ -110,6 +115,8 @@ func (s *Service) CreateObject(ctx context.Context, actorID, gardenID int64, in
Plantable: plantable,
Color: in.Color,
Props: in.Props,
GridSizeCM: in.GridSizeCM,
SnapToGrid: in.SnapToGrid,
Notes: strings.TrimSpace(in.Notes),
}
if err := finalizeObject(o, g); err != nil {
@@ -229,6 +236,12 @@ func applyObjectPatch(o *domain.GardenObject, p ObjectPatch) {
if p.SetProps {
o.Props = p.Props
}
if p.GridSizeCM != nil {
o.GridSizeCM = *p.GridSizeCM
}
if p.SnapToGrid != nil {
o.SnapToGrid = *p.SnapToGrid
}
if p.Notes != nil {
o.Notes = strings.TrimSpace(*p.Notes)
}
@@ -251,6 +264,15 @@ func finalizeObject(o *domain.GardenObject, g *domain.Garden) error {
if !validDimensionCM(o.WidthCM) || !validDimensionCM(o.HeightCM) {
return domain.ErrInvalidInput
}
// Grid spacing shares the dimension range [1 cm, 100 m]; 0 means "unset" and
// is defaulted so a create (or an older client omitting it) still lands a
// usable bed grid.
if o.GridSizeCM == 0 {
o.GridSizeCM = defaultObjectGridCM
}
if !validDimensionCM(o.GridSizeCM) {
return domain.ErrInvalidInput
}
if !isFinite(o.XCM) || !isFinite(o.YCM) || !isFinite(o.RotationDeg) {
return domain.ErrInvalidInput
}