Add configurable grid + snap to the layout system
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:
+32
-15
@@ -13,11 +13,12 @@ import (
|
||||
// sanity range that also rejects NaN/Inf and absurd values. Name/notes are
|
||||
// length-capped so untrusted input can't balloon storage.
|
||||
const (
|
||||
defaultGardenCM = 1000 // 10 m
|
||||
minGardenCM = 1 // 1 cm
|
||||
maxGardenCM = 10_000 // 100 m
|
||||
maxGardenNameLen = 200
|
||||
maxGardenNotesLen = 10_000
|
||||
defaultGardenCM = 1000 // 10 m
|
||||
minGardenCM = 1 // 1 cm
|
||||
maxGardenCM = 10_000 // 100 m
|
||||
defaultGardenGridCM = 100 // 1 m, matching the editor's previous fixed grid
|
||||
maxGardenNameLen = 200
|
||||
maxGardenNotesLen = 10_000
|
||||
)
|
||||
|
||||
// gardenRole ranks a user's access to a garden. Higher includes lower
|
||||
@@ -48,11 +49,13 @@ func (r gardenRole) String() string {
|
||||
|
||||
// GardenInput is the mutable field set for creating or updating a garden.
|
||||
type GardenInput struct {
|
||||
Name string
|
||||
WidthCM float64
|
||||
HeightCM float64
|
||||
UnitPref string
|
||||
Notes string
|
||||
Name string
|
||||
WidthCM float64
|
||||
HeightCM float64
|
||||
UnitPref string
|
||||
Notes string
|
||||
GridSizeCM float64
|
||||
SnapToGrid bool
|
||||
}
|
||||
|
||||
// requireGardenRole loads a garden and enforces that the actor holds at least
|
||||
@@ -198,12 +201,26 @@ func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error)
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
// Grid spacing shares the garden dimension range [1 cm, 100 m]. 0 means
|
||||
// "unset" — defaulted (not just on create: an older client that omits it must
|
||||
// not wipe the column to an invalid 0), so a saved garden always has a usable
|
||||
// grid even if snapping is off.
|
||||
grid := in.GridSizeCM
|
||||
if grid == 0 {
|
||||
grid = defaultGardenGridCM
|
||||
}
|
||||
if !validDimensionCM(grid) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
return &domain.Garden{
|
||||
Name: name,
|
||||
WidthCM: width,
|
||||
HeightCM: height,
|
||||
UnitPref: unit,
|
||||
Notes: notes,
|
||||
Name: name,
|
||||
WidthCM: width,
|
||||
HeightCM: height,
|
||||
UnitPref: unit,
|
||||
Notes: notes,
|
||||
GridSizeCM: grid,
|
||||
SnapToGrid: in.SnapToGrid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ func TestCreateGardenValidation(t *testing.T) {
|
||||
{Name: "X", WidthCM: math.NaN()}, // NaN slips past naive < / >
|
||||
{Name: "X", HeightCM: math.Inf(1)}, // +Inf
|
||||
{Name: "X", WidthCM: math.SmallestNonzeroFloat64}, // subnormal, > 0 but < 1 cm
|
||||
{Name: "X", GridSizeCM: maxGardenCM + 1}, // grid over the cap
|
||||
}
|
||||
for i, in := range cases {
|
||||
if _, err := s.CreateGarden(context.Background(), owner, in); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
@@ -70,6 +71,51 @@ func TestCreateGardenValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGardenGridSettings(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[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) {
|
||||
s := newTestService(t, openConfig())
|
||||
alice := seedUser(t, s, "[email protected]")
|
||||
|
||||
@@ -10,9 +10,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
maxObjectNameLen = 200
|
||||
maxObjectNotesLen = 10_000
|
||||
maxObjectPropsLen = 20_000
|
||||
maxObjectNameLen = 200
|
||||
maxObjectNotesLen = 10_000
|
||||
maxObjectPropsLen = 20_000
|
||||
defaultObjectGridCM = 30 // ~1 ft, a practical bed cell for plant snapping
|
||||
)
|
||||
|
||||
// objectKinds maps each valid garden_objects.kind to its traits. plantable is
|
||||
@@ -43,6 +44,8 @@ type ObjectInput struct {
|
||||
Plantable *bool
|
||||
Color *string
|
||||
Props *string
|
||||
GridSizeCM float64
|
||||
SnapToGrid bool
|
||||
Notes string
|
||||
}
|
||||
|
||||
@@ -65,6 +68,8 @@ type ObjectPatch struct {
|
||||
Color *string
|
||||
SetProps bool
|
||||
Props *string
|
||||
GridSizeCM *float64
|
||||
SnapToGrid *bool
|
||||
Notes *string
|
||||
}
|
||||
|
||||
@@ -110,6 +115,8 @@ func (s *Service) CreateObject(ctx context.Context, actorID, gardenID int64, in
|
||||
Plantable: plantable,
|
||||
Color: in.Color,
|
||||
Props: in.Props,
|
||||
GridSizeCM: in.GridSizeCM,
|
||||
SnapToGrid: in.SnapToGrid,
|
||||
Notes: strings.TrimSpace(in.Notes),
|
||||
}
|
||||
if err := finalizeObject(o, g); err != nil {
|
||||
@@ -229,6 +236,12 @@ func applyObjectPatch(o *domain.GardenObject, p ObjectPatch) {
|
||||
if p.SetProps {
|
||||
o.Props = p.Props
|
||||
}
|
||||
if p.GridSizeCM != nil {
|
||||
o.GridSizeCM = *p.GridSizeCM
|
||||
}
|
||||
if p.SnapToGrid != nil {
|
||||
o.SnapToGrid = *p.SnapToGrid
|
||||
}
|
||||
if p.Notes != nil {
|
||||
o.Notes = strings.TrimSpace(*p.Notes)
|
||||
}
|
||||
@@ -251,6 +264,15 @@ func finalizeObject(o *domain.GardenObject, g *domain.Garden) error {
|
||||
if !validDimensionCM(o.WidthCM) || !validDimensionCM(o.HeightCM) {
|
||||
return domain.ErrInvalidInput
|
||||
}
|
||||
// Grid spacing shares the dimension range [1 cm, 100 m]; 0 means "unset" and
|
||||
// is defaulted so a create (or an older client omitting it) still lands a
|
||||
// usable bed grid.
|
||||
if o.GridSizeCM == 0 {
|
||||
o.GridSizeCM = defaultObjectGridCM
|
||||
}
|
||||
if !validDimensionCM(o.GridSizeCM) {
|
||||
return domain.ErrInvalidInput
|
||||
}
|
||||
if !isFinite(o.XCM) || !isFinite(o.YCM) || !isFinite(o.RotationDeg) {
|
||||
return domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
s := newTestService(t, openConfig())
|
||||
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, Props: strPtr("{not json")}, // bad props
|
||||
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, Name: strings.Repeat("x", maxObjectNameLen+1)}, // name too long
|
||||
{Kind: domain.KindBed, XCM: 500, YCM: 500, WidthCM: 100, HeightCM: 100, GridSizeCM: maxGardenCM + 1}, // grid over the cap
|
||||
}
|
||||
for i, in := range bad {
|
||||
if _, err := s.CreateObject(context.Background(), owner, g.ID, in); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
|
||||
Reference in New Issue
Block a user