Configurable grid + snap-to-grid in the layout system #45

Merged
steve merged 2 commits from feat/layout-grid into main 2026-07-19 07:07:15 +00:00
23 changed files with 581 additions and 88 deletions
+6 -1
View File
@@ -19,10 +19,15 @@ type gardenFields 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"`
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]")
+8
View File
@@ -127,6 +127,10 @@ 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"`
// 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"` Version int64 `json:"version"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
@@ -174,6 +178,10 @@ 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
// 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"` Notes string `json:"notes"`
Version int64 `json:"version"` Version int64 `json:"version"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
+20 -1
View File
@@ -16,6 +16,7 @@ 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
defaultGardenGridCM = 100 // 1 m, matching the editor's previous fixed grid
maxGardenNameLen = 200 maxGardenNameLen = 200
maxGardenNotesLen = 10_000 maxGardenNotesLen = 10_000
) )
@@ -53,6 +54,8 @@ type GardenInput struct {
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
@@ -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 // gardenFromInput validates and normalizes input into a domain.Garden (without
// identity/ownership fields). With applyDefaults, absent dimensions/unit are // 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) { func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) {
name := strings.TrimSpace(in.Name) name := strings.TrimSpace(in.Name)
if name == "" || len(name) > maxGardenNameLen { if name == "" || len(name) > maxGardenNameLen {
@@ -198,12 +203,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]")
+22
View File
@@ -13,6 +13,7 @@ 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
Review

🟡 boolToInt + scan-to-int64-then-bool idiom now duplicated across 3 scan sites; worth a shared helper

maintainability · flagged by 1 model

  • internal/store/gardens.go:17 (and internal/store/objects.go:20) — The boolToInt write + scan-into-local-int64-then-map-to-bool (g.SnapToGrid = snap != 0) idiom is now present in three scan sites: scanGarden (gardens.go:17–26), scanGardenWithRole (gardens.go:33–43), and scanObject (objects.go:17–30, where it mirrors the pre-existing plantable pattern). The duplication is now more apparent; a tiny scanBool/boolPtr-style helper would be cleaner. Not a blocker — the exist…

🪰 Gadfly · advisory

🟡 **boolToInt + scan-to-int64-then-bool idiom now duplicated across 3 scan sites; worth a shared helper** _maintainability · flagged by 1 model_ - **`internal/store/gardens.go:17` (and `internal/store/objects.go:20`)** — The `boolToInt` write + scan-into-local-`int64`-then-map-to-bool (`g.SnapToGrid = snap != 0`) idiom is now present in three scan sites: `scanGarden` (gardens.go:17–26), `scanGardenWithRole` (gardens.go:33–43), and `scanObject` (objects.go:17–30, where it mirrors the pre-existing `plantable` pattern). The duplication is now more apparent; a tiny `scanBool`/`boolPtr`-style helper would be cleaner. Not a blocker — the exist… <sub>🪰 Gadfly · advisory</sub>
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.
Review

100 cm grid magic number now duplicated across migration, defaultGardenGridCM, and DEFAULT_GRID_CM with no shared anchor

maintainability · flagged by 1 model

  • internal/store/migrations/0005_grid_settings.sql:5 — The migration comment references the now-removed fixed grid; the 100 cm garden-grid default now lives in three unanchored places: the migration DEFAULT 100 (line 13), defaultGardenGridCM = 100 in internal/service/gardens.go, and DEFAULT_GRID_CM = 100 in web/src/components/gardens/GardenFormModal.tsx:22. Verified by grepping the repo — GRID_CM appears only in GardenFormModal.tsx; the 100 magic number has no shared anch…

🪰 Gadfly · advisory

⚪ **100 cm grid magic number now duplicated across migration, defaultGardenGridCM, and DEFAULT_GRID_CM with no shared anchor** _maintainability · flagged by 1 model_ - **`internal/store/migrations/0005_grid_settings.sql:5`** — The migration comment references the now-removed fixed grid; the `100` cm garden-grid default now lives in three unanchored places: the migration `DEFAULT 100` (line 13), `defaultGardenGridCM = 100` in `internal/service/gardens.go`, and `DEFAULT_GRID_CM = 100` in `web/src/components/gardens/GardenFormModal.tsx:22`. Verified by grepping the repo — `GRID_CM` appears only in `GardenFormModal.tsx`; the `100` magic number has no shared anch… <sub>🪰 Gadfly · advisory</sub>
-- 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) => {
Review

🟡 changeUnit's convertGrid duplicates the convert arrow; a single convertWith helper would deduplicate

maintainability · flagged by 2 models

🪰 Gadfly · advisory

🟡 **changeUnit's convertGrid duplicates the convert arrow; a single convertWith helper would deduplicate** _maintainability · flagged by 2 models_ <sub>🪰 Gadfly · advisory</sub>
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>}
+70 -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)
Review

🟡 Magic number 1e-6 in bedGridLines loop boundary

maintainability · flagged by 1 model

  • web/src/editor/GardenCanvas.tsx:32 — The 1e-6 epsilon in the bedGridLines loop boundary is an unexplained magic number. Give it a named constant (e.g., GRID_LINE_EPSILON) and a brief comment so maintainers know it guards against floating-point overshoot during accumulation.

🪰 Gadfly · advisory

🟡 **Magic number 1e-6 in bedGridLines loop boundary** _maintainability · flagged by 1 model_ - `web/src/editor/GardenCanvas.tsx:32` — The `1e-6` epsilon in the `bedGridLines` loop boundary is an unexplained magic number. Give it a named constant (e.g., `GRID_LINE_EPSILON`) and a brief comment so maintainers know it guards against floating-point overshoot during accumulation. <sub>🪰 Gadfly · advisory</sub>
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,14 @@ 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)) // snapLocalToBedGrid clamps to the bed either way; step 0 (snapping off) makes
const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.y)) // it a pure clamp, so both paths go through one helper.
const { x, y } = snapLocalToBedGrid(
local,
focusedObject.snapToGrid ? focusedObject.gridSizeCm : 0,
Outdated
Review

🟠 Duplicated inline clamp logic for bed bounds

maintainability · flagged by 2 models

  • web/src/editor/GardenCanvas.tsx:166 — The non-snapping branch in onPlace manually inlines the same clamp logic that already exists in PlopOverlay.tsx's clampLocal helper and inside snapLocalToBedGrid. Extract a shared clampLocal(point, halfW, halfH) into geometry.ts and use it in both overlays so bed-bounds clamping lives in one place and future changes (e.g., inset margins) only need one edit.

🪰 Gadfly · advisory

🟠 **Duplicated inline clamp logic for bed bounds** _maintainability · flagged by 2 models_ - `web/src/editor/GardenCanvas.tsx:166` — The non-snapping branch in `onPlace` manually inlines the same clamp logic that already exists in `PlopOverlay.tsx`'s `clampLocal` helper and inside `snapLocalToBedGrid`. Extract a shared `clampLocal(point, halfW, halfH)` into `geometry.ts` and use it in both overlays so bed-bounds clamping lives in one place and future changes (e.g., inset margins) only need one edit. <sub>🪰 Gadfly · advisory</sub>
focusedObject.widthCm / 2,
focusedObject.heightCm / 2,
)
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 +174,20 @@ 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.
// Memoized so a high-frequency plop drag (which re-renders the canvas on every
// pointermove) doesn't rebuild the line arrays each frame. null when the focused
// object isn't a snapping plantable bed.
Outdated
Review

🟠 bedGridLines arrays recreated on every render without memoization

performance · flagged by 3 models

  • web/src/editor/GardenCanvas.tsx:182bedVLines and bedHLines are computed inline without useMemo, so bedGridLines allocates fresh arrays on every render. GardenCanvas subscribes to livePlanting (and liveObject), so it re-renders on every pointermove during drag; the bed grid line arrays are recreated and diffed even though the focused bed's dimensions and grid size haven't changed. For a 200-line cap this is bounded, but it's still unnecessary garbage and React diff work on a…

🪰 Gadfly · advisory

🟠 **bedGridLines arrays recreated on every render without memoization** _performance · flagged by 3 models_ - `web/src/editor/GardenCanvas.tsx:182` — `bedVLines` and `bedHLines` are computed inline without `useMemo`, so `bedGridLines` allocates fresh arrays on every render. `GardenCanvas` subscribes to `livePlanting` (and `liveObject`), so it re-renders on every pointermove during drag; the bed grid line arrays are recreated and diffed even though the focused bed's dimensions and grid size haven't changed. For a 200-line cap this is bounded, but it's still unnecessary garbage and React diff work on a… <sub>🪰 Gadfly · advisory</sub>
const bedGrid = useMemo(() => {
if (!focusedObject || !focusedObject.plantable || !focusedObject.snapToGrid) return null
return {
v: bedGridLines(focusedObject.widthCm / 2, focusedObject.gridSizeCm),
h: bedGridLines(focusedObject.heightCm / 2, focusedObject.gridSizeCm),
}
}, [focusedObject])
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 +200,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 +216,17 @@ export function GardenCanvas({
</g> </g>
))} ))}
{focusedObject && bedGrid && (
<g transform={objectTransform(focusedObject)} pointerEvents="none">
{bedGrid.v.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" />
))}
{bedGrid.h.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 +239,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"
+11 -7
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)
@@ -57,11 +63,6 @@ export function PlopOverlay({
} }
} }
const clampLocal = (p: Point): Point => ({
x: Math.max(-halfW, Math.min(halfW, p.x)),
y: Math.max(-halfH, Math.min(halfH, p.y)),
})
const begin = ( const begin = (
e: ReactPointerEvent, e: ReactPointerEvent,
onMove: (e: PointerEvent) => EditorPlanting, onMove: (e: PointerEvent) => EditorPlanting,
@@ -103,7 +104,10 @@ 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 either way; step 0 (snapping off) makes it a
// pure clamp, so both paths go through one helper.
const next = snapLocalToBedGrid(moved, snap ? gridCm : 0, halfW, halfH)
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 }),
1
+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 {
Review

🟡 snapLocalToBedGrid duplicates the anchor-snap formula per axis instead of reusing snapValue-style factoring

maintainability · flagged by 1 model

  • web/src/lib/geometry.ts:93 (snapLocalToBedGrid) — The inline ternary step > 0 ? -halfW + Math.round((p.x + halfW) / step) * step : p.x interleaves snapping and passthrough per axis, duplicating the anchored-snap formula across both axes. snapValue/snapPoint already exist for the origin-anchored case; a snapToCorner(v, origin, step) helper (or snapValue(p.x + halfW, step) - halfW) would deduplicate and match the existing "anchor the snap, then clamp" shape. Verified by reading…

🪰 Gadfly · advisory

🟡 **snapLocalToBedGrid duplicates the anchor-snap formula per axis instead of reusing snapValue-style factoring** _maintainability · flagged by 1 model_ - **`web/src/lib/geometry.ts:93` (`snapLocalToBedGrid`)** — The inline ternary `step > 0 ? -halfW + Math.round((p.x + halfW) / step) * step : p.x` interleaves snapping and passthrough per axis, duplicating the anchored-snap formula across both axes. `snapValue`/`snapPoint` already exist for the origin-anchored case; a `snapToCorner(v, origin, step)` helper (or `snapValue(p.x + halfW, step) - halfW`) would deduplicate and match the existing "anchor the snap, then clamp" shape. Verified by reading… <sub>🪰 Gadfly · advisory</sub>
// Snap one axis to the grid anchored at -half (the bed's near edge), then clamp
// to [-half, half]. step<=0 skips the snap, so callers can pass step 0 to get a
// pure clamp — the non-snapping placement/move path — through the same helper.
const snapAxis = (v: number, half: number) =>
Math.max(-half, Math.min(half, step > 0 ? -half + Math.round((v + half) / step) * step : v))
return { x: snapAxis(p.x, halfW), y: snapAxis(p.y, halfH) }
}
/** 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 (