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
+11 -6
View File
@@ -14,15 +14,20 @@ import (
// Dimensions are centimeters (the API is metric-only; imperial is a display // Dimensions are centimeters (the API is metric-only; imperial is a display
// concern). On create, omitted dimensions default server-side. // concern). On create, omitted dimensions default server-side.
type gardenFields struct { type gardenFields struct {
Name string `json:"name" binding:"required"` Name string `json:"name" binding:"required"`
WidthCM float64 `json:"widthCm"` WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"` HeightCM float64 `json:"heightCm"`
UnitPref string `json:"unitPref"` UnitPref string `json:"unitPref"`
Notes string `json:"notes"` Notes string `json:"notes"`
GridSizeCM float64 `json:"gridSizeCm"`
SnapToGrid bool `json:"snapToGrid"`
} }
func (f gardenFields) toInput() service.GardenInput { 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 // gardenCreateRequest / gardenUpdateRequest are the JSON bodies. Update adds a
+8 -2
View File
@@ -27,6 +27,8 @@ type objectCreateRequest struct {
Plantable *bool `json:"plantable"` Plantable *bool `json:"plantable"`
Color *string `json:"color"` Color *string `json:"color"`
Props json.RawMessage `json:"props"` Props json.RawMessage `json:"props"`
GridSizeCM float64 `json:"gridSizeCm"`
SnapToGrid bool `json:"snapToGrid"`
Notes string `json:"notes"` Notes string `json:"notes"`
} }
@@ -35,7 +37,8 @@ func (r objectCreateRequest) toInput() service.ObjectInput {
Kind: r.Kind, Name: r.Name, Shape: r.Shape, Kind: r.Kind, Name: r.Name, Shape: r.Shape,
XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM, XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM,
RotationDeg: r.RotationDeg, ZIndex: r.ZIndex, Plantable: r.Plantable, 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"` Plantable *bool `json:"plantable"`
Color json.RawMessage `json:"color"` Color json.RawMessage `json:"color"`
Props json.RawMessage `json:"props"` Props json.RawMessage `json:"props"`
GridSizeCM *float64 `json:"gridSizeCm"`
SnapToGrid *bool `json:"snapToGrid"`
Notes *string `json:"notes"` Notes *string `json:"notes"`
Version int64 `json:"version" binding:"required,min=1"` Version int64 `json:"version" binding:"required,min=1"`
} }
@@ -67,7 +72,8 @@ func (r objectUpdateRequest) toPatch() (service.ObjectPatch, error) {
return service.ObjectPatch{ return service.ObjectPatch{
Name: r.Name, XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM, Name: r.Name, XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM,
RotationDeg: r.RotationDeg, ZIndex: r.ZIndex, Plantable: r.Plantable, 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 }, nil
} }
+50
View File
@@ -25,6 +25,56 @@ func objectsPath(id int64) string {
return "/api/v1/gardens/" + strconv.FormatInt(id, 10) + "/objects" 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, "[email protected]")
// 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) { func TestObjectCRUDAndFull(t *testing.T) {
r := authEngine(t, localCfg()) r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]") cookie := registerAndCookie(t, r, "[email protected]")
+12 -4
View File
@@ -127,9 +127,13 @@ type Garden struct {
HeightCM float64 `json:"heightCm"` HeightCM float64 `json:"heightCm"`
UnitPref string `json:"unitPref"` UnitPref string `json:"unitPref"`
Notes string `json:"notes"` Notes string `json:"notes"`
Version int64 `json:"version"` // GridSizeCM is the garden-scale grid spacing (cm) the editor draws its grid
CreatedAt string `json:"createdAt"` // from; SnapToGrid, when true, snaps objects to that grid on place/move/resize.
UpdatedAt string `json:"updatedAt"` 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", // MyRole is the requesting actor's effective role on this garden ("owner",
// "editor", or "viewer"). Computed by the service, never persisted. // "editor", or "viewer"). Computed by the service, never persisted.
@@ -174,7 +178,11 @@ type GardenObject struct {
Plantable bool `json:"plantable"` Plantable bool `json:"plantable"`
Color *string `json:"color,omitempty"` Color *string `json:"color,omitempty"`
Props *string `json:"props,omitempty"` // kind-specific JSON Props *string `json:"props,omitempty"` // kind-specific JSON
Notes string `json:"notes"` // 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"` Version int64 `json:"version"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
+32 -15
View File
@@ -13,11 +13,12 @@ import (
// sanity range that also rejects NaN/Inf and absurd values. Name/notes are // sanity range that also rejects NaN/Inf and absurd values. Name/notes are
// length-capped so untrusted input can't balloon storage. // length-capped so untrusted input can't balloon storage.
const ( const (
defaultGardenCM = 1000 // 10 m defaultGardenCM = 1000 // 10 m
minGardenCM = 1 // 1 cm minGardenCM = 1 // 1 cm
maxGardenCM = 10_000 // 100 m maxGardenCM = 10_000 // 100 m
maxGardenNameLen = 200 defaultGardenGridCM = 100 // 1 m, matching the editor's previous fixed grid
maxGardenNotesLen = 10_000 maxGardenNameLen = 200
maxGardenNotesLen = 10_000
) )
// gardenRole ranks a user's access to a garden. Higher includes lower // 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. // GardenInput is the mutable field set for creating or updating a garden.
type GardenInput struct { type GardenInput struct {
Name string Name string
WidthCM float64 WidthCM float64
HeightCM float64 HeightCM float64
UnitPref string UnitPref string
Notes string Notes string
GridSizeCM float64
SnapToGrid bool
} }
// requireGardenRole loads a garden and enforces that the actor holds at least // requireGardenRole loads a garden and enforces that the actor holds at least
@@ -198,12 +201,26 @@ func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error)
return nil, domain.ErrInvalidInput 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{ return &domain.Garden{
Name: name, Name: name,
WidthCM: width, WidthCM: width,
HeightCM: height, HeightCM: height,
UnitPref: unit, UnitPref: unit,
Notes: notes, Notes: notes,
GridSizeCM: grid,
SnapToGrid: in.SnapToGrid,
}, nil }, nil
} }
+46
View File
@@ -57,6 +57,7 @@ func TestCreateGardenValidation(t *testing.T) {
{Name: "X", WidthCM: math.NaN()}, // NaN slips past naive < / > {Name: "X", WidthCM: math.NaN()}, // NaN slips past naive < / >
{Name: "X", HeightCM: math.Inf(1)}, // +Inf {Name: "X", HeightCM: math.Inf(1)}, // +Inf
{Name: "X", WidthCM: math.SmallestNonzeroFloat64}, // subnormal, > 0 but < 1 cm {Name: "X", WidthCM: math.SmallestNonzeroFloat64}, // subnormal, > 0 but < 1 cm
{Name: "X", GridSizeCM: maxGardenCM + 1}, // grid over the cap
} }
for i, in := range cases { for i, in := range cases {
if _, err := s.CreateGarden(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) { 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, "[email protected]")
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) { func TestListGardensOwnedOnly(t *testing.T) {
s := newTestService(t, openConfig()) s := newTestService(t, openConfig())
alice := seedUser(t, s, "[email protected]") alice := seedUser(t, s, "[email protected]")
+25 -3
View File
@@ -10,9 +10,10 @@ import (
) )
const ( const (
maxObjectNameLen = 200 maxObjectNameLen = 200
maxObjectNotesLen = 10_000 maxObjectNotesLen = 10_000
maxObjectPropsLen = 20_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 // objectKinds maps each valid garden_objects.kind to its traits. plantable is
@@ -43,6 +44,8 @@ type ObjectInput struct {
Plantable *bool Plantable *bool
Color *string Color *string
Props *string Props *string
GridSizeCM float64
SnapToGrid bool
Notes string Notes string
} }
@@ -65,6 +68,8 @@ type ObjectPatch struct {
Color *string Color *string
SetProps bool SetProps bool
Props *string Props *string
GridSizeCM *float64
SnapToGrid *bool
Notes *string Notes *string
} }
@@ -110,6 +115,8 @@ func (s *Service) CreateObject(ctx context.Context, actorID, gardenID int64, in
Plantable: plantable, Plantable: plantable,
Color: in.Color, Color: in.Color,
Props: in.Props, Props: in.Props,
GridSizeCM: in.GridSizeCM,
SnapToGrid: in.SnapToGrid,
Notes: strings.TrimSpace(in.Notes), Notes: strings.TrimSpace(in.Notes),
} }
if err := finalizeObject(o, g); err != nil { if err := finalizeObject(o, g); err != nil {
@@ -229,6 +236,12 @@ func applyObjectPatch(o *domain.GardenObject, p ObjectPatch) {
if p.SetProps { if p.SetProps {
o.Props = p.Props o.Props = p.Props
} }
if p.GridSizeCM != nil {
o.GridSizeCM = *p.GridSizeCM
}
if p.SnapToGrid != nil {
o.SnapToGrid = *p.SnapToGrid
}
if p.Notes != nil { if p.Notes != nil {
o.Notes = strings.TrimSpace(*p.Notes) 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) { if !validDimensionCM(o.WidthCM) || !validDimensionCM(o.HeightCM) {
return domain.ErrInvalidInput 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) { if !isFinite(o.XCM) || !isFinite(o.YCM) || !isFinite(o.RotationDeg) {
return domain.ErrInvalidInput return domain.ErrInvalidInput
} }
+36
View File
@@ -48,6 +48,41 @@ func TestCreateObjectDefaults(t *testing.T) {
} }
} }
func TestObjectGridSettings(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
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) { func TestCreateObjectPlantableByKind(t *testing.T) {
s := newTestService(t, openConfig()) s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]") owner := seedUser(t, s, "[email protected]")
@@ -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, 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, 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, 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 { for i, in := range bad {
if _, err := s.CreateObject(context.Background(), owner, g.ID, in); !errors.Is(err, domain.ErrInvalidInput) { if _, err := s.CreateObject(context.Background(), owner, g.ID, in); !errors.Is(err, domain.ErrInvalidInput) {
+18 -9
View File
@@ -10,29 +10,37 @@ import (
) )
// gardenColumns lists the gardens columns in the fixed order scanGarden expects. // 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) { func scanGarden(s scanner) (*domain.Garden, error) {
var g domain.Garden var (
g domain.Garden
snap int64
)
if err := s.Scan( if err := s.Scan(
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM, &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 { ); err != nil {
return nil, err return nil, err
} }
g.SnapToGrid = snap != 0
return &g, nil return &g, nil
} }
// scanGardenWithRole reads a garden row plus a trailing my_role column into the // scanGardenWithRole reads a garden row plus a trailing my_role column into the
// computed Garden.MyRole field (used by the actor-scoped list). // computed Garden.MyRole field (used by the actor-scoped list).
func scanGardenWithRole(s scanner) (*domain.Garden, error) { func scanGardenWithRole(s scanner) (*domain.Garden, error) {
var g domain.Garden var (
g domain.Garden
snap int64
)
if err := s.Scan( if err := s.Scan(
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM, &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 { ); err != nil {
return nil, err return nil, err
} }
g.SnapToGrid = snap != 0
return &g, nil return &g, nil
} }
@@ -45,10 +53,10 @@ const maxGardensListed = 1000
// set and validated by the service) and returns the stored row. // set and validated by the service) and returns the stored row.
func (d *DB) CreateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden, error) { func (d *DB) CreateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden, error) {
created, err := scanGarden(d.sql.QueryRowContext(ctx, created, err := scanGarden(d.sql.QueryRowContext(ctx,
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes) `INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+gardenColumns, 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 { if err != nil {
return nil, fmt.Errorf("store: insert garden: %w", err) 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, updated, err := scanGarden(d.sql.QueryRowContext(ctx,
`UPDATE gardens `UPDATE gardens
SET name = ?, width_cm = ?, height_cm = ?, unit_pref = ?, notes = ?, SET name = ?, width_cm = ?, height_cm = ?, unit_pref = ?, notes = ?,
grid_size_cm = ?, snap_to_grid = ?,
version = version + 1, version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ? WHERE id = ? AND version = ?
RETURNING `+gardenColumns, 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) { if errors.Is(err, sql.ErrNoRows) {
// No row matched: distinguish "gone" from "stale version". // 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. // 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, 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) { func scanObject(s scanner) (*domain.GardenObject, error) {
var ( var (
o domain.GardenObject o domain.GardenObject
plantable int64 plantable int64
snap int64
) )
if err := s.Scan( if err := s.Scan(
&o.ID, &o.GardenID, &o.Kind, &o.Name, &o.Shape, &o.Points, &o.ID, &o.GardenID, &o.Kind, &o.Name, &o.Shape, &o.Points,
&o.XCM, &o.YCM, &o.WidthCM, &o.HeightCM, &o.RotationDeg, &o.ZIndex, &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 { ); err != nil {
return nil, err return nil, err
} }
o.Plantable = plantable != 0 o.Plantable = plantable != 0
o.SnapToGrid = snap != 0
return &o, nil 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, created, err := scanObject(d.sql.QueryRowContext(ctx,
`INSERT INTO garden_objects `INSERT INTO garden_objects
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm, (garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
rotation_deg, z_index, plantable, color, props, notes) rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+objectColumns, RETURNING `+objectColumns,
o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM, 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 { if err != nil {
return nil, fmt.Errorf("store: insert object: %w", err) 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 `UPDATE garden_objects
SET kind = ?, name = ?, shape = ?, points = ?, x_cm = ?, y_cm = ?, SET kind = ?, name = ?, shape = ?, points = ?, x_cm = ?, y_cm = ?,
width_cm = ?, height_cm = ?, rotation_deg = ?, z_index = ?, 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, version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ? WHERE id = ? AND version = ?
RETURNING `+objectColumns, RETURNING `+objectColumns,
o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM, 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, o.ID, o.Version,
)) ))
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+55 -1
View File
@@ -9,13 +9,17 @@ import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens' import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import { import {
cmFromDisplay, cmFromDisplay,
cmFromSpacing,
dimensionUnitLabel, dimensionUnitLabel,
displayFromCm, displayFromCm,
isValidDimensionCm, isValidDimensionCm,
spacingFromCm,
spacingUnitLabel,
type UnitPref, type UnitPref,
} from '@/lib/units' } from '@/lib/units'
const DEFAULT_METERS = 10 // matches the server's 10 m default 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 = [ const unitOptions = [
{ value: 'metric', label: 'Metric (m)' }, { value: 'metric', label: 'Metric (m)' },
@@ -42,6 +46,10 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric') const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric')
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric')) const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
const [height, setHeight] = useState(() => dimString(garden?.heightCm, 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 [notes, setNotes] = useState(garden?.notes ?? '')
const [version, setVersion] = useState(garden?.version ?? 0) const [version, setVersion] = useState(garden?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null) const [conflict, setConflict] = useState<string | null>(null)
@@ -52,8 +60,15 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
const v = parseFloat(s) const v = parseFloat(s)
return Number.isFinite(v) ? String(displayFromCm(cmFromDisplay(v, unit), next)) : 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)) setWidth(convert(width))
setHeight(convert(height)) setHeight(convert(height))
setGridSize(convertGrid(gridSize))
setUnit(next) 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.') setFormError('Width and height must be between 1 cm and 100 m.')
return 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 { try {
if (isEdit) { if (isEdit) {
@@ -95,6 +123,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
setUnit(current.unitPref) setUnit(current.unitPref)
setWidth(dimString(current.widthCm, current.unitPref)) setWidth(dimString(current.widthCm, current.unitPref))
setHeight(dimString(current.heightCm, current.unitPref)) setHeight(dimString(current.heightCm, current.unitPref))
setGridSize(String(spacingFromCm(current.gridSizeCm, current.unitPref)))
setSnapToGrid(current.snapToGrid)
setNotes(current.notes) setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.') setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
return return
@@ -145,6 +175,30 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
/> />
</div> </div>
<div className="flex items-end gap-3">
<div className="flex-1">
<TextField
label={`Grid size (${spacingUnitLabel(unit)})`}
name="gridSize"
type="number"
inputMode="decimal"
step="any"
min="0"
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
/>
</div>
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
<input
type="checkbox"
checked={snapToGrid}
onChange={(e) => setSnapToGrid(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
Snap objects
</label>
</div>
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} /> <TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
{formError && <Alert>{formError}</Alert>} {formError && <Alert>{formError}</Alert>}
+64 -10
View File
@@ -1,5 +1,5 @@
import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react' import { useEffect, useId, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { screenToWorld, worldToLocal, type Rect, type Size } from '@/lib/geometry' import { screenToWorld, snapLocalToBedGrid, snapPoint, worldToLocal, type Rect, type Size } from '@/lib/geometry'
import { useCreateObject, useCreatePlanting } from '@/lib/objects' import { useCreateObject, useCreatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants' import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings' import type { EditorPlanting } from '@/lib/plantings'
@@ -13,15 +13,26 @@ import { useEditorStore } from './store'
import { useViewport } from './useViewport' import { useViewport } from './useViewport'
import type { EditorGarden, EditorObject } from './types' import type { EditorGarden, EditorObject } from './types'
const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6 const GRID_MIN_CELL_PX = 6
const GRID_MAX_OPACITY = 0.18 const GRID_MAX_OPACITY = 0.18
const BED_GRID_MAX_LINES = 200 // safety cap on lines drawn inside one bed
/** The world-space bounds rect of an object (center + size → top-left rect). */ /** The world-space bounds rect of an object (center + size → top-left rect). */
function objectRect(o: EditorObject): Rect { function objectRect(o: EditorObject): Rect {
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm } return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
} }
/** Grid-line offsets (from a bed's top-left corner, in the object-local frame)
* for a bed of the given half-extents and grid step. Starts at the corner so the
* lines match snapLocalToBedGrid, and is capped so a tiny grid on a big bed can't
* emit thousands of lines. Returns cm offsets along one axis. */
function bedGridLines(half: number, step: number): number[] {
if (!(step > 0) || (2 * half) / step > BED_GRID_MAX_LINES) return []
const lines: number[] = []
for (let v = -half; v <= half + 1e-6; v += step) lines.push(v)
return lines
}
/** /**
* The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/ * The editor canvas. Pan/zoom/pinch, tap-to-place objects, select+move/resize/
* rotate an object, and — the #15 core — focus into a plantable object to place, * rotate an object, and — the #15 core — focus into a plantable object to place,
@@ -88,7 +99,8 @@ export function GardenCanvas({
fitToRect(target ? objectRect(target) : gardenRect, size) fitToRect(target ? objectRect(target) : gardenRect, size)
}, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect]) }, [size, garden.id, garden.widthCm, garden.heightCm, focusedObjectId, objects, gardenRect, fitToRect])
const cellPx = GRID_CM * viewport.scale const gridCm = garden.gridSizeCm
const cellPx = gridCm * viewport.scale
const gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY)) const gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY))
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects]) const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
@@ -117,7 +129,9 @@ export function GardenCanvas({
const def = kindDef(armed) const def = kindDef(armed)
const rect = svgRef.current?.getBoundingClientRect() const rect = svgRef.current?.getBoundingClientRect()
if (!def || !rect) return if (!def || !rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) let world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
// Snap the new object's center to the garden grid when the garden opts in.
if (garden.snapToGrid) world = snapPoint(world, gridCm)
createObject.mutate( createObject.mutate(
{ kind: def.kind, shape: def.shape, xCm: world.x, yCm: world.y, widthCm: def.widthCm, heightCm: def.heightCm, zIndex: def.defaultZ }, { kind: def.kind, shape: def.shape, xCm: world.x, yCm: world.y, widthCm: def.widthCm, heightCm: def.heightCm, zIndex: def.defaultZ },
{ onSuccess: (o) => select(o.id) }, { onSuccess: (o) => select(o.id) },
@@ -143,8 +157,15 @@ export function GardenCanvas({
if (!rect) return if (!rect) return
const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport) const world = screenToWorld({ x: e.clientX - rect.left, y: e.clientY - rect.top }, viewport)
const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg) const local = worldToLocal(world, { x: focusedObject.xCm, y: focusedObject.yCm }, focusedObject.rotationDeg)
const x = Math.max(-focusedObject.widthCm / 2, Math.min(focusedObject.widthCm / 2, local.x)) const halfW = focusedObject.widthCm / 2
const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.y)) const halfH = focusedObject.heightCm / 2
// Snap the plant to the bed's grid when the bed opts in; snapLocalToBedGrid
// also clamps, so the plain clamp covers the non-snapping case.
const placed = focusedObject.snapToGrid
? snapLocalToBedGrid(local, focusedObject.gridSizeCm, halfW, halfH)
: { x: Math.max(-halfW, Math.min(halfW, local.x)), y: Math.max(-halfH, Math.min(halfH, local.y)) }
const x = placed.x
const y = placed.y
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15) const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
// Stay armed for repeat-placement; don't select (the placement sheet covers // Stay armed for repeat-placement; don't select (the placement sheet covers
// the object, so a selection would be hidden until placement ends anyway). // the object, so a selection would be hidden until placement ends anyway).
@@ -154,6 +175,13 @@ export function GardenCanvas({
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0 const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0 const halfFH = focusedObject ? focusedObject.heightCm / 2 : 0
// Draw the bed's own grid inside a focused plantable bed that has snapping on,
// so the user sees exactly where plants will land. Lines are in the bed's local
// frame (origin at center), anchored to the corner to match snapLocalToBedGrid.
const showBedGrid = !!focusedObject && focusedObject.plantable && focusedObject.snapToGrid
const bedVLines = showBedGrid ? bedGridLines(halfFW, focusedObject.gridSizeCm) : []
const bedHLines = showBedGrid ? bedGridLines(halfFH, focusedObject.gridSizeCm) : []
return ( return (
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg"> <div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
<svg <svg
@@ -166,8 +194,8 @@ export function GardenCanvas({
{gridOpacity > 0.005 && ( {gridOpacity > 0.005 && (
<> <>
<defs> <defs>
<pattern id={gridId} width={GRID_CM} height={GRID_CM} patternUnits="userSpaceOnUse"> <pattern id={gridId} width={gridCm} height={gridCm} patternUnits="userSpaceOnUse">
<path d={`M ${GRID_CM} 0 L 0 0 0 ${GRID_CM}`} fill="none" stroke="#808080" strokeOpacity={gridOpacity} strokeWidth={1} vectorEffect="non-scaling-stroke" /> <path d={`M ${gridCm} 0 L 0 0 0 ${gridCm}`} fill="none" stroke="#808080" strokeOpacity={gridOpacity} strokeWidth={1} vectorEffect="non-scaling-stroke" />
</pattern> </pattern>
</defs> </defs>
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} /> <rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
@@ -182,6 +210,17 @@ export function GardenCanvas({
</g> </g>
))} ))}
{showBedGrid && focusedObject && (
<g transform={objectTransform(focusedObject)} pointerEvents="none">
{bedVLines.map((x) => (
<line key={`v${x}`} x1={x} y1={-halfFH} x2={x} y2={halfFH} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
))}
{bedHLines.map((y) => (
<line key={`h${y}`} x1={-halfFW} y1={y} x2={halfFW} y2={y} stroke="#3f8f4f" strokeOpacity={0.3} strokeWidth={1} vectorEffect="non-scaling-stroke" />
))}
</g>
)}
<PlopLayer <PlopLayer
objects={rendered} objects={rendered}
plantings={renderedPlops} plantings={renderedPlops}
@@ -194,9 +233,24 @@ export function GardenCanvas({
{/* Edit handles are mounted only for editors/owners; viewers can still {/* Edit handles are mounted only for editors/owners; viewers can still
select to inspect (read-only), but never move/resize. */} select to inspect (read-only), but never move/resize. */}
{canEdit && selectedObject && <SelectionOverlay object={selectedObject} gardenId={garden.id} svgRef={svgRef} />} {canEdit && selectedObject && (
<SelectionOverlay
object={selectedObject}
gardenId={garden.id}
svgRef={svgRef}
snap={garden.snapToGrid}
gridCm={gridCm}
/>
)}
{canEdit && selectedPlop && selectedPlopObject && ( {canEdit && selectedPlop && selectedPlopObject && (
<PlopOverlay plop={selectedPlop} object={selectedPlopObject} gardenId={garden.id} svgRef={svgRef} /> <PlopOverlay
plop={selectedPlop}
object={selectedPlopObject}
gardenId={garden.id}
svgRef={svgRef}
snap={selectedPlopObject.snapToGrid}
gridCm={selectedPlopObject.gridSizeCm}
/>
)} )}
{/* Placement capture: a transparent sheet over the focused object while a {/* Placement capture: a transparent sheet over the focused object while a
+46 -1
View File
@@ -5,9 +5,12 @@ import { TextArea } from '@/components/ui/TextArea'
import { useDeleteObject, useUpdateObject } from '@/lib/objects' import { useDeleteObject, useUpdateObject } from '@/lib/objects'
import { import {
cmFromDisplay, cmFromDisplay,
cmFromSpacing,
dimensionUnitLabel, dimensionUnitLabel,
displayFromCm, displayFromCm,
MIN_DIMENSION_CM, MIN_DIMENSION_CM,
spacingFromCm,
spacingUnitLabel,
type UnitPref, type UnitPref,
} from '@/lib/units' } from '@/lib/units'
import { kindDef } from './kinds' import { kindDef } from './kinds'
@@ -48,6 +51,7 @@ export function Inspector({
const [y, setY] = useState(String(displayFromCm(object.yCm, unit))) const [y, setY] = useState(String(displayFromCm(object.yCm, unit)))
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg))) const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
const [color, setColor] = useState(object.color ?? DEFAULT_COLOR) const [color, setColor] = useState(object.color ?? DEFAULT_COLOR)
const [gridSize, setGridSize] = useState(String(spacingFromCm(object.gridSizeCm, unit)))
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
@@ -64,7 +68,8 @@ export function Inspector({
setY(String(displayFromCm(object.yCm, unit))) setY(String(displayFromCm(object.yCm, unit)))
setRotation(String(Math.round(object.rotationDeg))) setRotation(String(Math.round(object.rotationDeg)))
setColor(object.color ?? DEFAULT_COLOR) setColor(object.color ?? DEFAULT_COLOR)
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit]) setGridSize(String(spacingFromCm(object.gridSizeCm, unit)))
}, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, object.gridSizeCm, unit])
const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) => { const patch = (fields: Partial<Omit<EditorObject, 'id' | 'version'>>) => {
if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit if (readOnly) return // a blur mustn't fire a mutation if the role changed mid-edit
@@ -84,6 +89,17 @@ export function Inspector({
if (cm !== current) apply(cm) if (cm !== current) apply(cm)
} }
// Commit the bed grid size (entered at spacing scale, cm/in). Compare at display
// precision so a blur without an edit doesn't fire a spurious PATCH; ignore a
// sub-1cm value the server would reject.
const commitGrid = () => {
const v = parseFloat(gridSize)
if (!Number.isFinite(v)) return
if (v === spacingFromCm(object.gridSizeCm, unit)) return
const cm = cmFromSpacing(v, unit)
if (cm >= MIN_DIMENSION_CM && cm !== object.gridSizeCm) patch({ gridSizeCm: cm })
}
const u = dimensionUnitLabel(unit) const u = dimensionUnitLabel(unit)
return ( return (
@@ -218,6 +234,35 @@ export function Inspector({
Plantable Plantable
</label> </label>
{/* Bed grid: only plantable beds place plants, so the plant-snapping grid
is shown just for them. */}
{object.plantable && (
<div className="flex items-end gap-2">
<div className="flex-1">
<TextField
label={`Bed grid (${spacingUnitLabel(unit)})`}
name="gridSize"
type="number"
inputMode="decimal"
step="any"
min="0"
value={gridSize}
onChange={(e) => setGridSize(e.target.value)}
onBlur={commitGrid}
/>
</div>
<label className="flex h-9 items-center gap-2 whitespace-nowrap text-sm text-fg">
<input
type="checkbox"
checked={object.snapToGrid}
onChange={(e) => patch({ snapToGrid: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
Snap plants
</label>
</div>
)}
<TextArea <TextArea
label="Notes" label="Notes"
name="notes" name="notes"
+10 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react' import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react'
import { screenToWorld, worldToLocal, type Point } from '@/lib/geometry' import { screenToWorld, snapLocalToBedGrid, worldToLocal, type Point } from '@/lib/geometry'
import { useUpdatePlanting } from '@/lib/objects' import { useUpdatePlanting } from '@/lib/objects'
import type { EditorPlanting } from '@/lib/plantings' import type { EditorPlanting } from '@/lib/plantings'
import { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared' import { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared'
@@ -20,11 +20,17 @@ export function PlopOverlay({
object, object,
gardenId, gardenId,
svgRef, svgRef,
snap,
gridCm,
}: { }: {
plop: EditorPlanting plop: EditorPlanting
object: EditorObject object: EditorObject
gardenId: number gardenId: number
svgRef: RefObject<SVGSVGElement | null> svgRef: RefObject<SVGSVGElement | null>
// The parent bed's grid: when snap is true, moving the plop snaps it to the
// bed grid (radius stays free either way).
snap: boolean
gridCm: number
}) { }) {
const setLivePlanting = useEditorStore((s) => s.setLivePlanting) const setLivePlanting = useEditorStore((s) => s.setLivePlanting)
const setObjectDragging = useEditorStore((s) => s.setObjectDragging) const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
@@ -103,7 +109,9 @@ export function PlopOverlay({
e, e,
(ev) => { (ev) => {
const p = ptr(ev) const p = ptr(ev)
const next = clampLocal({ x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) }) const moved: Point = { x: base.xCm + (p.x - start.x), y: base.yCm + (p.y - start.y) }
// snapLocalToBedGrid clamps too, so the plain clamp covers the non-snap case.
const next = snap ? snapLocalToBedGrid(moved, gridCm, halfW, halfH) : clampLocal(moved)
return { ...base, xCm: next.x, yCm: next.y } return { ...base, xCm: next.x, yCm: next.y }
}, },
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }), (f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
+19 -4
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, type PointerEvent as ReactPointerEvent, type RefObject } from 'react' 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 { useUpdateObject } from '@/lib/objects'
import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared' import { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared'
import { useEditorStore } from './store' import { useEditorStore } from './store'
@@ -27,10 +27,16 @@ export function SelectionOverlay({
object, object,
gardenId, gardenId,
svgRef, svgRef,
snap,
gridCm,
}: { }: {
object: EditorObject object: EditorObject
gardenId: number gardenId: number
svgRef: RefObject<SVGSVGElement | null> 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 setLiveObject = useEditorStore((s) => s.setLiveObject)
const setObjectDragging = useEditorStore((s) => s.setObjectDragging) const setObjectDragging = useEditorStore((s) => s.setObjectDragging)
@@ -112,7 +118,9 @@ export function SelectionOverlay({
e, e,
(ev) => { (ev) => {
const world = pointerWorld(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 }), (f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
) )
@@ -128,8 +136,15 @@ export function SelectionOverlay({
(ev) => { (ev) => {
const world = pointerWorld(ev) const world = pointerWorld(ev)
const p = worldToLocal(world, center0, base.rotationDeg) const p = worldToLocal(world, center0, base.rotationDeg)
const newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x)) let 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 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 draggedLocal: Point = { x: oppositeLocal.x + sx * newW, y: oppositeLocal.y + sy * newH }
const newCenterLocal: Point = { const newCenterLocal: Point = {
x: (oppositeLocal.x + draggedLocal.x) / 2, x: (oppositeLocal.x + draggedLocal.x) / 2,
+7
View File
@@ -17,6 +17,10 @@ export interface EditorObject {
rotationDeg: number rotationDeg: number
zIndex: number zIndex: number
color?: string | null color?: string | null
// This object's local grid spacing (cm) and whether plants snap to it inside
// the bed. Only meaningful for plantable objects.
gridSizeCm: number
snapToGrid: boolean
notes: string notes: string
plantable: boolean plantable: boolean
version: number version: number
@@ -29,4 +33,7 @@ export interface EditorGarden {
widthCm: number widthCm: number
heightCm: number heightCm: number
unitPref: UnitPref unitPref: UnitPref
// The garden-scale grid the canvas draws, and whether objects snap to it.
gridSizeCm: number
snapToGrid: boolean
} }
+4
View File
@@ -19,6 +19,8 @@ export const gardenSchema = z.object({
heightCm: z.number(), heightCm: z.number(),
unitPref: unitPrefSchema, unitPref: unitPrefSchema,
notes: z.string(), notes: z.string(),
gridSizeCm: z.number(),
snapToGrid: z.boolean(),
version: z.number(), version: z.number(),
createdAt: z.string(), createdAt: z.string(),
updatedAt: z.string(), updatedAt: z.string(),
@@ -55,6 +57,8 @@ export interface GardenInput {
heightCm: number heightCm: number
unitPref: UnitPref unitPref: UnitPref
notes: string notes: string
gridSizeCm: number
snapToGrid: boolean
} }
export function useCreateGarden() { export function useCreateGarden() {
+37
View File
@@ -3,6 +3,9 @@ import {
clampScale, clampScale,
localToWorld, localToWorld,
screenToWorld, screenToWorld,
snapLocalToBedGrid,
snapPoint,
snapValue,
worldToLocal, worldToLocal,
worldToScreen, worldToScreen,
zoomToFitRect, zoomToFitRect,
@@ -91,3 +94,37 @@ describe('zoomToFitRect', () => {
expect(fit.scale).toBe(20) 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)
})
})
+31
View File
@@ -68,6 +68,37 @@ export function clampScale(scale: number, min: number, max: number): number {
return Math.min(max, Math.max(min, scale)) 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 {
const sx = step > 0 ? -halfW + Math.round((p.x + halfW) / step) * step : p.x
const sy = step > 0 ? -halfH + Math.round((p.y + halfH) / step) * step : p.y
return {
x: Math.max(-halfW, Math.min(halfW, sx)),
y: Math.max(-halfH, Math.min(halfH, sy)),
}
}
/** Linear interpolation. */ /** Linear interpolation. */
export function lerp(a: number, b: number, t: number): number { export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t return a + (b - a) * t
+10
View File
@@ -30,6 +30,8 @@ export const serverObjectSchema = z.object({
plantable: z.boolean(), plantable: z.boolean(),
color: z.string().nullable().optional(), color: z.string().nullable().optional(),
props: z.string().nullable().optional(), props: z.string().nullable().optional(),
gridSizeCm: z.number(),
snapToGrid: z.boolean(),
notes: z.string(), notes: z.string(),
version: z.number(), version: z.number(),
}) })
@@ -59,6 +61,8 @@ export function toEditorObject(o: ServerObject): EditorObject {
rotationDeg: o.rotationDeg, rotationDeg: o.rotationDeg,
zIndex: o.zIndex, zIndex: o.zIndex,
color: o.color ?? null, color: o.color ?? null,
gridSizeCm: o.gridSizeCm,
snapToGrid: o.snapToGrid,
notes: o.notes, notes: o.notes,
plantable: o.plantable, plantable: o.plantable,
version: o.version, version: o.version,
@@ -110,6 +114,8 @@ export interface ObjectCreate {
zIndex?: number zIndex?: number
plantable?: boolean plantable?: boolean
color?: string | null color?: string | null
gridSizeCm?: number
snapToGrid?: boolean
} }
export function useCreateObject(gardenId: number) { export function useCreateObject(gardenId: number) {
@@ -138,6 +144,8 @@ export interface ObjectPatch {
zIndex?: number zIndex?: number
plantable?: boolean plantable?: boolean
color?: string | null color?: string | null
gridSizeCm?: number
snapToGrid?: boolean
notes?: string notes?: string
} }
@@ -392,6 +400,8 @@ function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject {
...(patch.zIndex !== undefined ? { zIndex: patch.zIndex } : {}), ...(patch.zIndex !== undefined ? { zIndex: patch.zIndex } : {}),
...(patch.plantable !== undefined ? { plantable: patch.plantable } : {}), ...(patch.plantable !== undefined ? { plantable: patch.plantable } : {}),
...(patch.color !== undefined ? { color: patch.color } : {}), ...(patch.color !== undefined ? { color: patch.color } : {}),
...(patch.gridSizeCm !== undefined ? { gridSizeCm: patch.gridSizeCm } : {}),
...(patch.snapToGrid !== undefined ? { snapToGrid: patch.snapToGrid } : {}),
...(patch.notes !== undefined ? { notes: patch.notes } : {}), ...(patch.notes !== undefined ? { notes: patch.notes } : {}),
} }
} }
+2
View File
@@ -228,6 +228,8 @@ export function GardenEditorPage() {
widthCm: g.widthCm, widthCm: g.widthCm,
heightCm: g.heightCm, heightCm: g.heightCm,
unitPref: g.unitPref, unitPref: g.unitPref,
gridSizeCm: g.gridSizeCm,
snapToGrid: g.snapToGrid,
} }
const selectedObject = objects.find((o) => o.id === selectedId) ?? null const selectedObject = objects.find((o) => o.id === selectedId) ?? null
+2
View File
@@ -48,6 +48,8 @@ export function PublicGardenPage() {
widthCm: g.widthCm, widthCm: g.widthCm,
heightCm: g.heightCm, heightCm: g.heightCm,
unitPref: g.unitPref, unitPref: g.unitPref,
gridSizeCm: g.gridSizeCm,
snapToGrid: g.snapToGrid,
} }
return ( return (