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
+18 -9
View File
@@ -10,29 +10,37 @@ import (
)
// gardenColumns lists the gardens columns in the fixed order scanGarden expects.
const gardenColumns = `id, owner_id, name, width_cm, height_cm, unit_pref, notes, version, created_at, updated_at`
const gardenColumns = `id, owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid, version, created_at, updated_at`
func scanGarden(s scanner) (*domain.Garden, error) {
var g domain.Garden
var (
g domain.Garden
snap int64
)
if err := s.Scan(
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM,
&g.UnitPref, &g.Notes, &g.Version, &g.CreatedAt, &g.UpdatedAt,
&g.UnitPref, &g.Notes, &g.GridSizeCM, &snap, &g.Version, &g.CreatedAt, &g.UpdatedAt,
); err != nil {
return nil, err
}
g.SnapToGrid = snap != 0
return &g, nil
}
// scanGardenWithRole reads a garden row plus a trailing my_role column into the
// computed Garden.MyRole field (used by the actor-scoped list).
func scanGardenWithRole(s scanner) (*domain.Garden, error) {
var g domain.Garden
var (
g domain.Garden
snap int64
)
if err := s.Scan(
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM,
&g.UnitPref, &g.Notes, &g.Version, &g.CreatedAt, &g.UpdatedAt, &g.MyRole,
&g.UnitPref, &g.Notes, &g.GridSizeCM, &snap, &g.Version, &g.CreatedAt, &g.UpdatedAt, &g.MyRole,
); err != nil {
return nil, err
}
g.SnapToGrid = snap != 0
return &g, nil
}
@@ -45,10 +53,10 @@ const maxGardensListed = 1000
// set and validated by the service) and returns the stored row.
func (d *DB) CreateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden, error) {
created, err := scanGarden(d.sql.QueryRowContext(ctx,
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes)
VALUES (?, ?, ?, ?, ?, ?)
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+gardenColumns,
g.OwnerID, g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes,
g.OwnerID, g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.GridSizeCM, boolToInt(g.SnapToGrid),
))
if err != nil {
return nil, fmt.Errorf("store: insert garden: %w", err)
@@ -117,11 +125,12 @@ func (d *DB) UpdateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden
updated, err := scanGarden(d.sql.QueryRowContext(ctx,
`UPDATE gardens
SET name = ?, width_cm = ?, height_cm = ?, unit_pref = ?, notes = ?,
grid_size_cm = ?, snap_to_grid = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+gardenColumns,
g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.ID, g.Version,
g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.GridSizeCM, boolToInt(g.SnapToGrid), g.ID, g.Version,
))
if errors.Is(err, sql.ErrNoRows) {
// No row matched: distinguish "gone" from "stale version".
@@ -0,0 +1,17 @@
-- Grid + snap settings for the layout system.
--
-- Two independent grids:
-- * gardens.grid_size_cm / snap_to_grid — the garden-scale grid the visual grid
-- is drawn from and (when snap is on) objects snap to when placed/moved/resized.
-- Defaults to 100 cm (1 m), matching the editor's previous fixed grid.
-- * garden_objects.grid_size_cm / snap_to_grid — a per-object grid used inside a
-- plantable bed: when snap is on, plants snap to it. Defaults to 30 cm (~1 ft),
-- a practical bed cell.
--
-- snap defaults to off on both, so existing gardens keep free placement until the
-- owner (garden grid) or an editor (bed grid) opts in.
ALTER TABLE gardens ADD COLUMN grid_size_cm REAL NOT NULL DEFAULT 100;
ALTER TABLE gardens ADD COLUMN snap_to_grid INTEGER NOT NULL DEFAULT 0 CHECK (snap_to_grid IN (0, 1));
ALTER TABLE garden_objects ADD COLUMN grid_size_cm REAL NOT NULL DEFAULT 30;
ALTER TABLE garden_objects ADD COLUMN snap_to_grid INTEGER NOT NULL DEFAULT 0 CHECK (snap_to_grid IN (0, 1));
+11 -7
View File
@@ -11,21 +11,23 @@ import (
// objectColumns lists garden_objects columns in the order scanObject expects.
const objectColumns = `id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
rotation_deg, z_index, plantable, color, props, notes, version, created_at, updated_at`
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes, version, created_at, updated_at`
func scanObject(s scanner) (*domain.GardenObject, error) {
var (
o domain.GardenObject
plantable int64
snap int64
)
if err := s.Scan(
&o.ID, &o.GardenID, &o.Kind, &o.Name, &o.Shape, &o.Points,
&o.XCM, &o.YCM, &o.WidthCM, &o.HeightCM, &o.RotationDeg, &o.ZIndex,
&plantable, &o.Color, &o.Props, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
&plantable, &o.Color, &o.Props, &o.GridSizeCM, &snap, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
); err != nil {
return nil, err
}
o.Plantable = plantable != 0
o.SnapToGrid = snap != 0
return &o, nil
}
@@ -35,11 +37,12 @@ func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.
created, err := scanObject(d.sql.QueryRowContext(ctx,
`INSERT INTO garden_objects
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
rotation_deg, z_index, plantable, color, props, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+objectColumns,
o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
))
if err != nil {
return nil, fmt.Errorf("store: insert object: %w", err)
@@ -95,13 +98,14 @@ func (d *DB) UpdateObject(ctx context.Context, o *domain.GardenObject) (*domain.
`UPDATE garden_objects
SET kind = ?, name = ?, shape = ?, points = ?, x_cm = ?, y_cm = ?,
width_cm = ?, height_cm = ?, rotation_deg = ?, z_index = ?,
plantable = ?, color = ?, props = ?, notes = ?,
plantable = ?, color = ?, props = ?, grid_size_cm = ?, snap_to_grid = ?, notes = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+objectColumns,
o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
o.ID, o.Version,
))
if errors.Is(err, sql.ErrNoRows) {