From e74fb308c16abba30640e8c82c32fb10aaf22a1d Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sun, 19 Jul 2026 07:07:14 +0000 Subject: [PATCH] Configurable grid + snap-to-grid in the layout system (#45) 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 --- internal/api/gardens.go | 17 ++-- internal/api/objects.go | 10 ++- internal/api/objects_test.go | 50 ++++++++++++ internal/domain/domain.go | 36 +++++---- internal/service/gardens.go | 51 ++++++++---- internal/service/gardens_test.go | 46 +++++++++++ internal/service/objects.go | 28 ++++++- internal/service/objects_test.go | 36 +++++++++ internal/store/gardens.go | 27 ++++--- .../store/migrations/0005_grid_settings.sql | 17 ++++ internal/store/objects.go | 18 +++-- .../components/gardens/GardenFormModal.tsx | 56 ++++++++++++- web/src/editor/GardenCanvas.tsx | 80 ++++++++++++++++--- web/src/editor/Inspector.tsx | 63 ++++++++++++--- web/src/editor/PlopOverlay.tsx | 18 +++-- web/src/editor/SelectionOverlay.tsx | 23 +++++- web/src/editor/types.ts | 7 ++ web/src/lib/gardens.ts | 4 + web/src/lib/geometry.test.ts | 37 +++++++++ web/src/lib/geometry.ts | 31 +++++++ web/src/lib/objects.ts | 10 +++ web/src/pages/GardenEditorPage.tsx | 2 + web/src/pages/PublicGardenPage.tsx | 2 + 23 files changed, 581 insertions(+), 88 deletions(-) create mode 100644 internal/store/migrations/0005_grid_settings.sql diff --git a/internal/api/gardens.go b/internal/api/gardens.go index fe9190b..0718933 100644 --- a/internal/api/gardens.go +++ b/internal/api/gardens.go @@ -14,15 +14,20 @@ import ( // Dimensions are centimeters (the API is metric-only; imperial is a display // concern). On create, omitted dimensions default server-side. type gardenFields struct { - Name string `json:"name" binding:"required"` - WidthCM float64 `json:"widthCm"` - HeightCM float64 `json:"heightCm"` - UnitPref string `json:"unitPref"` - Notes string `json:"notes"` + Name string `json:"name" binding:"required"` + WidthCM float64 `json:"widthCm"` + HeightCM float64 `json:"heightCm"` + UnitPref string `json:"unitPref"` + Notes string `json:"notes"` + GridSizeCM float64 `json:"gridSizeCm"` + SnapToGrid bool `json:"snapToGrid"` } func (f gardenFields) toInput() service.GardenInput { - return service.GardenInput{Name: f.Name, WidthCM: f.WidthCM, HeightCM: f.HeightCM, UnitPref: f.UnitPref, Notes: f.Notes} + return service.GardenInput{ + Name: f.Name, WidthCM: f.WidthCM, HeightCM: f.HeightCM, UnitPref: f.UnitPref, Notes: f.Notes, + GridSizeCM: f.GridSizeCM, SnapToGrid: f.SnapToGrid, + } } // gardenCreateRequest / gardenUpdateRequest are the JSON bodies. Update adds a diff --git a/internal/api/objects.go b/internal/api/objects.go index 641716f..7b52d75 100644 --- a/internal/api/objects.go +++ b/internal/api/objects.go @@ -27,6 +27,8 @@ type objectCreateRequest struct { Plantable *bool `json:"plantable"` Color *string `json:"color"` Props json.RawMessage `json:"props"` + GridSizeCM float64 `json:"gridSizeCm"` + SnapToGrid bool `json:"snapToGrid"` Notes string `json:"notes"` } @@ -35,7 +37,8 @@ func (r objectCreateRequest) toInput() service.ObjectInput { Kind: r.Kind, Name: r.Name, Shape: r.Shape, XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM, RotationDeg: r.RotationDeg, ZIndex: r.ZIndex, Plantable: r.Plantable, - Color: r.Color, Props: propsFromRaw(r.Props), Notes: r.Notes, + Color: r.Color, Props: propsFromRaw(r.Props), + GridSizeCM: r.GridSizeCM, SnapToGrid: r.SnapToGrid, Notes: r.Notes, } } @@ -54,6 +57,8 @@ type objectUpdateRequest struct { Plantable *bool `json:"plantable"` Color json.RawMessage `json:"color"` Props json.RawMessage `json:"props"` + GridSizeCM *float64 `json:"gridSizeCm"` + SnapToGrid *bool `json:"snapToGrid"` Notes *string `json:"notes"` Version int64 `json:"version" binding:"required,min=1"` } @@ -67,7 +72,8 @@ func (r objectUpdateRequest) toPatch() (service.ObjectPatch, error) { return service.ObjectPatch{ Name: r.Name, XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM, RotationDeg: r.RotationDeg, ZIndex: r.ZIndex, Plantable: r.Plantable, - SetColor: setColor, Color: color, SetProps: setProps, Props: props, Notes: r.Notes, + SetColor: setColor, Color: color, SetProps: setProps, Props: props, + GridSizeCM: r.GridSizeCM, SnapToGrid: r.SnapToGrid, Notes: r.Notes, }, nil } diff --git a/internal/api/objects_test.go b/internal/api/objects_test.go index 9f2dea9..a8f79d0 100644 --- a/internal/api/objects_test.go +++ b/internal/api/objects_test.go @@ -25,6 +25,56 @@ func objectsPath(id int64) string { return "/api/v1/gardens/" + strconv.FormatInt(id, 10) + "/objects" } +// TestGridFieldsRoundTripAPI guards the JSON wiring (tags + toInput/toPatch) for +// the garden and object grid/snap fields end-to-end through the HTTP handlers. +func TestGridFieldsRoundTripAPI(t *testing.T) { + r := authEngine(t, localCfg()) + cookie := registerAndCookie(t, r, "grid@example.com") + + // A garden created with no grid echoes the 1 m default, snapping off. + w := doJSON(t, r, http.MethodPost, "/api/v1/gardens", map[string]any{"name": "G"}, cookie) + if w.Code != http.StatusCreated { + t.Fatalf("create garden: status %d, body %s", w.Code, w.Body.String()) + } + g := decodeMap(t, w.Body.Bytes()) + gid := int64(g["id"].(float64)) + if g["gridSizeCm"].(float64) != 100 || g["snapToGrid"].(bool) { + t.Errorf("garden grid defaults = %v / %v, want 100 / false", g["gridSizeCm"], g["snapToGrid"]) + } + + // Patching the garden's grid + snap echoes the new values. + w = doJSON(t, r, http.MethodPatch, gardenPath(gid), map[string]any{ + "name": "G", "widthCm": 300, "heightCm": 200, "unitPref": "metric", + "gridSizeCm": 25, "snapToGrid": true, "version": 1, + }, cookie) + if w.Code != http.StatusOK { + t.Fatalf("patch garden: status %d, body %s", w.Code, w.Body.String()) + } + if g := decodeMap(t, w.Body.Bytes()); g["gridSizeCm"].(float64) != 25 || !g["snapToGrid"].(bool) { + t.Errorf("patched garden grid = %v / %v, want 25 / true", g["gridSizeCm"], g["snapToGrid"]) + } + + // A bed created with no grid echoes the 30 cm default; a patch sets grid + snap. + w = doJSON(t, r, http.MethodPost, objectsPath(gid), + map[string]any{"kind": "bed", "xCm": 150, "yCm": 100, "widthCm": 120, "heightCm": 80}, cookie) + if w.Code != http.StatusCreated { + t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String()) + } + o := decodeMap(t, w.Body.Bytes()) + oid := int64(o["id"].(float64)) + if o["gridSizeCm"].(float64) != 30 || o["snapToGrid"].(bool) { + t.Errorf("object grid defaults = %v / %v, want 30 / false", o["gridSizeCm"], o["snapToGrid"]) + } + w = doJSON(t, r, http.MethodPatch, objectPath(oid), + map[string]any{"gridSizeCm": 15, "snapToGrid": true, "version": 1}, cookie) + if w.Code != http.StatusOK { + t.Fatalf("patch object: status %d, body %s", w.Code, w.Body.String()) + } + if o := decodeMap(t, w.Body.Bytes()); o["gridSizeCm"].(float64) != 15 || !o["snapToGrid"].(bool) { + t.Errorf("patched object grid = %v / %v, want 15 / true", o["gridSizeCm"], o["snapToGrid"]) + } +} + func TestObjectCRUDAndFull(t *testing.T) { r := authEngine(t, localCfg()) cookie := registerAndCookie(t, r, "a@example.com") diff --git a/internal/domain/domain.go b/internal/domain/domain.go index 2d6faa7..1183185 100644 --- a/internal/domain/domain.go +++ b/internal/domain/domain.go @@ -120,16 +120,20 @@ type Session struct { // Garden is a planning surface owned by one user, at real-world scale (cm). type Garden struct { - ID int64 `json:"id"` - OwnerID int64 `json:"ownerId"` - Name string `json:"name"` - WidthCM float64 `json:"widthCm"` - HeightCM float64 `json:"heightCm"` - UnitPref string `json:"unitPref"` - Notes string `json:"notes"` - Version int64 `json:"version"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + ID int64 `json:"id"` + OwnerID int64 `json:"ownerId"` + Name string `json:"name"` + WidthCM float64 `json:"widthCm"` + HeightCM float64 `json:"heightCm"` + UnitPref string `json:"unitPref"` + Notes string `json:"notes"` + // GridSizeCM is the garden-scale grid spacing (cm) the editor draws its grid + // from; SnapToGrid, when true, snaps objects to that grid on place/move/resize. + GridSizeCM float64 `json:"gridSizeCm"` + SnapToGrid bool `json:"snapToGrid"` + Version int64 `json:"version"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` // MyRole is the requesting actor's effective role on this garden ("owner", // "editor", or "viewer"). Computed by the service, never persisted. @@ -174,10 +178,14 @@ type GardenObject struct { Plantable bool `json:"plantable"` Color *string `json:"color,omitempty"` Props *string `json:"props,omitempty"` // kind-specific JSON - Notes string `json:"notes"` - Version int64 `json:"version"` - CreatedAt string `json:"createdAt"` - UpdatedAt string `json:"updatedAt"` + // GridSizeCM is this object's local grid spacing (cm); when SnapToGrid is true + // and the object is a plantable bed, plants snap to it inside the bed. + GridSizeCM float64 `json:"gridSizeCm"` + SnapToGrid bool `json:"snapToGrid"` + Notes string `json:"notes"` + Version int64 `json:"version"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` } // Plant is a catalog entry. OwnerID nil means a read-only seeded built-in. diff --git a/internal/service/gardens.go b/internal/service/gardens.go index c3fb5f6..460b3d7 100644 --- a/internal/service/gardens.go +++ b/internal/service/gardens.go @@ -13,11 +13,12 @@ import ( // sanity range that also rejects NaN/Inf and absurd values. Name/notes are // length-capped so untrusted input can't balloon storage. const ( - defaultGardenCM = 1000 // 10 m - minGardenCM = 1 // 1 cm - maxGardenCM = 10_000 // 100 m - maxGardenNameLen = 200 - maxGardenNotesLen = 10_000 + defaultGardenCM = 1000 // 10 m + minGardenCM = 1 // 1 cm + maxGardenCM = 10_000 // 100 m + defaultGardenGridCM = 100 // 1 m, matching the editor's previous fixed grid + maxGardenNameLen = 200 + maxGardenNotesLen = 10_000 ) // gardenRole ranks a user's access to a garden. Higher includes lower @@ -48,11 +49,13 @@ func (r gardenRole) String() string { // GardenInput is the mutable field set for creating or updating a garden. type GardenInput struct { - Name string - WidthCM float64 - HeightCM float64 - UnitPref string - Notes string + Name string + WidthCM float64 + HeightCM float64 + UnitPref string + Notes string + GridSizeCM float64 + SnapToGrid bool } // requireGardenRole loads a garden and enforces that the actor holds at least @@ -163,7 +166,9 @@ func (s *Service) DeleteGarden(ctx context.Context, actorID, gardenID int64) err // gardenFromInput validates and normalizes input into a domain.Garden (without // identity/ownership fields). With applyDefaults, absent dimensions/unit are -// filled in (create); without it, every field must be supplied (update). +// filled in (create); without it, dimensions and unit must be supplied (update). +// The grid size is the exception: 0 always means "unset" and is defaulted on both +// paths, so an older client that omits it can't wipe the column to an invalid 0. func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) { name := strings.TrimSpace(in.Name) if name == "" || len(name) > maxGardenNameLen { @@ -198,12 +203,26 @@ func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) return nil, domain.ErrInvalidInput } + // Grid spacing shares the garden dimension range [1 cm, 100 m]. 0 means + // "unset" — defaulted (not just on create: an older client that omits it must + // not wipe the column to an invalid 0), so a saved garden always has a usable + // grid even if snapping is off. + grid := in.GridSizeCM + if grid == 0 { + grid = defaultGardenGridCM + } + if !validDimensionCM(grid) { + return nil, domain.ErrInvalidInput + } + return &domain.Garden{ - Name: name, - WidthCM: width, - HeightCM: height, - UnitPref: unit, - Notes: notes, + Name: name, + WidthCM: width, + HeightCM: height, + UnitPref: unit, + Notes: notes, + GridSizeCM: grid, + SnapToGrid: in.SnapToGrid, }, nil } diff --git a/internal/service/gardens_test.go b/internal/service/gardens_test.go index 45c0e1a..2ec485e 100644 --- a/internal/service/gardens_test.go +++ b/internal/service/gardens_test.go @@ -57,6 +57,7 @@ func TestCreateGardenValidation(t *testing.T) { {Name: "X", WidthCM: math.NaN()}, // NaN slips past naive < / > {Name: "X", HeightCM: math.Inf(1)}, // +Inf {Name: "X", WidthCM: math.SmallestNonzeroFloat64}, // subnormal, > 0 but < 1 cm + {Name: "X", GridSizeCM: maxGardenCM + 1}, // grid over the cap } for i, in := range cases { if _, err := s.CreateGarden(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) { @@ -70,6 +71,51 @@ func TestCreateGardenValidation(t *testing.T) { } } +func TestGardenGridSettings(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "grid@example.com") + ctx := context.Background() + + // An omitted grid size lands the default; snap defaults off. + g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "G"}) + if err != nil { + t.Fatalf("CreateGarden: %v", err) + } + if g.GridSizeCM != defaultGardenGridCM { + t.Errorf("grid = %v, want default %d", g.GridSizeCM, defaultGardenGridCM) + } + if g.SnapToGrid { + t.Error("snap should default off") + } + + // An update sets an explicit grid and turns snap on; both persist. + up, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{ + Name: "G", WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref, + GridSizeCM: 25, SnapToGrid: true, + }, g.Version) + if err != nil { + t.Fatalf("UpdateGarden: %v", err) + } + got, err := s.GetGarden(ctx, owner, g.ID) + if err != nil { + t.Fatalf("GetGarden: %v", err) + } + if got.GridSizeCM != 25 || !got.SnapToGrid { + t.Errorf("persisted grid=%v snap=%v, want 25/true", got.GridSizeCM, got.SnapToGrid) + } + + // An update omitting the grid size (0) is re-defaulted, not stored as 0. + up2, err := s.UpdateGarden(ctx, owner, g.ID, GardenInput{ + Name: "G", WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref, + }, up.Version) + if err != nil { + t.Fatalf("UpdateGarden(reset): %v", err) + } + if up2.GridSizeCM != defaultGardenGridCM || up2.SnapToGrid { + t.Errorf("after omit grid=%v snap=%v, want default/false", up2.GridSizeCM, up2.SnapToGrid) + } +} + func TestListGardensOwnedOnly(t *testing.T) { s := newTestService(t, openConfig()) alice := seedUser(t, s, "alice@example.com") diff --git a/internal/service/objects.go b/internal/service/objects.go index a6eedce..d392bea 100644 --- a/internal/service/objects.go +++ b/internal/service/objects.go @@ -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 } diff --git a/internal/service/objects_test.go b/internal/service/objects_test.go index 71e4e97..1871305 100644 --- a/internal/service/objects_test.go +++ b/internal/service/objects_test.go @@ -48,6 +48,41 @@ func TestCreateObjectDefaults(t *testing.T) { } } +func TestObjectGridSettings(t *testing.T) { + s := newTestService(t, openConfig()) + owner := seedUser(t, s, "a@example.com") + g := seedGarden(t, s, owner) + ctx := context.Background() + + // An omitted grid size defaults; snap defaults off. + o, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{ + Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 100, + }) + if err != nil { + t.Fatalf("CreateObject: %v", err) + } + if o.GridSizeCM != defaultObjectGridCM { + t.Errorf("grid = %v, want default %d", o.GridSizeCM, defaultObjectGridCM) + } + if o.SnapToGrid { + t.Error("snap should default off") + } + + // Patching grid + snap round-trips and leaves other fields untouched. + grid := 15.0 + snap := true + up, err := s.UpdateObject(ctx, owner, o.ID, ObjectPatch{GridSizeCM: &grid, SnapToGrid: &snap}, o.Version) + if err != nil { + t.Fatalf("UpdateObject: %v", err) + } + if up.GridSizeCM != 15 || !up.SnapToGrid { + t.Errorf("after patch grid=%v snap=%v, want 15/true", up.GridSizeCM, up.SnapToGrid) + } + if up.WidthCM != 200 || up.HeightCM != 100 { + t.Errorf("a grid patch changed dimensions: %vx%v", up.WidthCM, up.HeightCM) + } +} + func TestCreateObjectPlantableByKind(t *testing.T) { s := newTestService(t, openConfig()) owner := seedUser(t, s, "a@example.com") @@ -105,6 +140,7 @@ func TestCreateObjectValidation(t *testing.T) { {Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Color: strPtr("nope")}, // bad color {Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Props: strPtr("{not json")}, // bad props {Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Name: strings.Repeat("x", maxObjectNameLen+1)}, // name too long + {Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, GridSizeCM: maxGardenCM + 1}, // grid over the cap } for i, in := range bad { if _, err := s.CreateObject(context.Background(), owner, g.ID, in); !errors.Is(err, domain.ErrInvalidInput) { diff --git a/internal/store/gardens.go b/internal/store/gardens.go index 8f157ee..caf01c9 100644 --- a/internal/store/gardens.go +++ b/internal/store/gardens.go @@ -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". diff --git a/internal/store/migrations/0005_grid_settings.sql b/internal/store/migrations/0005_grid_settings.sql new file mode 100644 index 0000000..2e80730 --- /dev/null +++ b/internal/store/migrations/0005_grid_settings.sql @@ -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)); diff --git a/internal/store/objects.go b/internal/store/objects.go index db801c3..b9af6f2 100644 --- a/internal/store/objects.go +++ b/internal/store/objects.go @@ -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) { diff --git a/web/src/components/gardens/GardenFormModal.tsx b/web/src/components/gardens/GardenFormModal.tsx index 14a7969..e1798f5 100644 --- a/web/src/components/gardens/GardenFormModal.tsx +++ b/web/src/components/gardens/GardenFormModal.tsx @@ -9,13 +9,17 @@ import { errorMessage } from '@/lib/api' import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens' import { cmFromDisplay, + cmFromSpacing, dimensionUnitLabel, displayFromCm, isValidDimensionCm, + spacingFromCm, + spacingUnitLabel, type UnitPref, } from '@/lib/units' const DEFAULT_METERS = 10 // matches the server's 10 m default +const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default const unitOptions = [ { value: 'metric', label: 'Metric (m)' }, @@ -42,6 +46,10 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: const [unit, setUnit] = useState(garden?.unitPref ?? 'metric') const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric')) const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric')) + const [gridSize, setGridSize] = useState(() => + String(spacingFromCm(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric')), + ) + const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false) const [notes, setNotes] = useState(garden?.notes ?? '') const [version, setVersion] = useState(garden?.version ?? 0) const [conflict, setConflict] = useState(null) @@ -52,8 +60,15 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: const v = parseFloat(s) return Number.isFinite(v) ? String(displayFromCm(cmFromDisplay(v, unit), next)) : s } + // The grid is entered at spacing scale (cm/in), so it converts with the + // spacing helpers, not the dimension ones. + const convertGrid = (s: string) => { + const v = parseFloat(s) + return Number.isFinite(v) ? String(spacingFromCm(cmFromSpacing(v, unit), next)) : s + } setWidth(convert(width)) setHeight(convert(height)) + setGridSize(convertGrid(gridSize)) setUnit(next) } @@ -75,8 +90,21 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: setFormError('Width and height must be between 1 cm and 100 m.') return } + const gridSizeCm = cmFromSpacing(parseFloat(gridSize), unit) + if (!isValidDimensionCm(gridSizeCm)) { + setFormError('Grid size must be between 1 cm and 100 m.') + return + } - const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim() } + const input = { + name: name.trim(), + widthCm, + heightCm, + unitPref: unit, + notes: notes.trim(), + gridSizeCm, + snapToGrid, + } try { if (isEdit) { @@ -95,6 +123,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: setUnit(current.unitPref) setWidth(dimString(current.widthCm, current.unitPref)) setHeight(dimString(current.heightCm, current.unitPref)) + setGridSize(String(spacingFromCm(current.gridSizeCm, current.unitPref))) + setSnapToGrid(current.snapToGrid) setNotes(current.notes) setConflict('This garden changed elsewhere. The latest values are shown — review and save again.') return @@ -145,6 +175,30 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose: /> +
+
+ setGridSize(e.target.value)} + /> +
+ +
+