Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s
Build image / build-and-push (push) Successful in 7s
Closes #44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details. Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #45.
This commit is contained in:
+11
-6
@@ -14,15 +14,20 @@ import (
|
||||
// Dimensions are centimeters (the API is metric-only; imperial is a display
|
||||
// concern). On create, omitted dimensions default server-side.
|
||||
type gardenFields struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
UnitPref string `json:"unitPref"`
|
||||
Notes string `json:"notes"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
UnitPref string `json:"unitPref"`
|
||||
Notes string `json:"notes"`
|
||||
GridSizeCM float64 `json:"gridSizeCm"`
|
||||
SnapToGrid bool `json:"snapToGrid"`
|
||||
}
|
||||
|
||||
func (f gardenFields) toInput() service.GardenInput {
|
||||
return service.GardenInput{Name: f.Name, WidthCM: f.WidthCM, HeightCM: f.HeightCM, UnitPref: f.UnitPref, Notes: f.Notes}
|
||||
return service.GardenInput{
|
||||
Name: f.Name, WidthCM: f.WidthCM, HeightCM: f.HeightCM, UnitPref: f.UnitPref, Notes: f.Notes,
|
||||
GridSizeCM: f.GridSizeCM, SnapToGrid: f.SnapToGrid,
|
||||
}
|
||||
}
|
||||
|
||||
// gardenCreateRequest / gardenUpdateRequest are the JSON bodies. Update adds a
|
||||
|
||||
@@ -27,6 +27,8 @@ type objectCreateRequest struct {
|
||||
Plantable *bool `json:"plantable"`
|
||||
Color *string `json:"color"`
|
||||
Props json.RawMessage `json:"props"`
|
||||
GridSizeCM float64 `json:"gridSizeCm"`
|
||||
SnapToGrid bool `json:"snapToGrid"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
@@ -35,7 +37,8 @@ func (r objectCreateRequest) toInput() service.ObjectInput {
|
||||
Kind: r.Kind, Name: r.Name, Shape: r.Shape,
|
||||
XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM,
|
||||
RotationDeg: r.RotationDeg, ZIndex: r.ZIndex, Plantable: r.Plantable,
|
||||
Color: r.Color, Props: propsFromRaw(r.Props), Notes: r.Notes,
|
||||
Color: r.Color, Props: propsFromRaw(r.Props),
|
||||
GridSizeCM: r.GridSizeCM, SnapToGrid: r.SnapToGrid, Notes: r.Notes,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +57,8 @@ type objectUpdateRequest struct {
|
||||
Plantable *bool `json:"plantable"`
|
||||
Color json.RawMessage `json:"color"`
|
||||
Props json.RawMessage `json:"props"`
|
||||
GridSizeCM *float64 `json:"gridSizeCm"`
|
||||
SnapToGrid *bool `json:"snapToGrid"`
|
||||
Notes *string `json:"notes"`
|
||||
Version int64 `json:"version" binding:"required,min=1"`
|
||||
}
|
||||
@@ -67,7 +72,8 @@ func (r objectUpdateRequest) toPatch() (service.ObjectPatch, error) {
|
||||
return service.ObjectPatch{
|
||||
Name: r.Name, XCM: r.XCM, YCM: r.YCM, WidthCM: r.WidthCM, HeightCM: r.HeightCM,
|
||||
RotationDeg: r.RotationDeg, ZIndex: r.ZIndex, Plantable: r.Plantable,
|
||||
SetColor: setColor, Color: color, SetProps: setProps, Props: props, Notes: r.Notes,
|
||||
SetColor: setColor, Color: color, SetProps: setProps, Props: props,
|
||||
GridSizeCM: r.GridSizeCM, SnapToGrid: r.SnapToGrid, Notes: r.Notes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,56 @@ func objectsPath(id int64) string {
|
||||
return "/api/v1/gardens/" + strconv.FormatInt(id, 10) + "/objects"
|
||||
}
|
||||
|
||||
// TestGridFieldsRoundTripAPI guards the JSON wiring (tags + toInput/toPatch) for
|
||||
// the garden and object grid/snap fields end-to-end through the HTTP handlers.
|
||||
func TestGridFieldsRoundTripAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[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) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
+22
-14
@@ -120,16 +120,20 @@ type Session struct {
|
||||
|
||||
// Garden is a planning surface owned by one user, at real-world scale (cm).
|
||||
type Garden struct {
|
||||
ID int64 `json:"id"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
Name string `json:"name"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
UnitPref string `json:"unitPref"`
|
||||
Notes string `json:"notes"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
ID int64 `json:"id"`
|
||||
OwnerID int64 `json:"ownerId"`
|
||||
Name string `json:"name"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
UnitPref string `json:"unitPref"`
|
||||
Notes string `json:"notes"`
|
||||
// GridSizeCM is the garden-scale grid spacing (cm) the editor draws its grid
|
||||
// from; SnapToGrid, when true, snaps objects to that grid on place/move/resize.
|
||||
GridSizeCM float64 `json:"gridSizeCm"`
|
||||
SnapToGrid bool `json:"snapToGrid"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
|
||||
// MyRole is the requesting actor's effective role on this garden ("owner",
|
||||
// "editor", or "viewer"). Computed by the service, never persisted.
|
||||
@@ -174,10 +178,14 @@ type GardenObject struct {
|
||||
Plantable bool `json:"plantable"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
Props *string `json:"props,omitempty"` // kind-specific JSON
|
||||
Notes string `json:"notes"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
// GridSizeCM is this object's local grid spacing (cm); when SnapToGrid is true
|
||||
// and the object is a plantable bed, plants snap to it inside the bed.
|
||||
GridSizeCM float64 `json:"gridSizeCm"`
|
||||
SnapToGrid bool `json:"snapToGrid"`
|
||||
Notes string `json:"notes"`
|
||||
Version int64 `json:"version"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Plant is a catalog entry. OwnerID nil means a read-only seeded built-in.
|
||||
|
||||
+35
-16
@@ -13,11 +13,12 @@ import (
|
||||
// sanity range that also rejects NaN/Inf and absurd values. Name/notes are
|
||||
// length-capped so untrusted input can't balloon storage.
|
||||
const (
|
||||
defaultGardenCM = 1000 // 10 m
|
||||
minGardenCM = 1 // 1 cm
|
||||
maxGardenCM = 10_000 // 100 m
|
||||
maxGardenNameLen = 200
|
||||
maxGardenNotesLen = 10_000
|
||||
defaultGardenCM = 1000 // 10 m
|
||||
minGardenCM = 1 // 1 cm
|
||||
maxGardenCM = 10_000 // 100 m
|
||||
defaultGardenGridCM = 100 // 1 m, matching the editor's previous fixed grid
|
||||
maxGardenNameLen = 200
|
||||
maxGardenNotesLen = 10_000
|
||||
)
|
||||
|
||||
// gardenRole ranks a user's access to a garden. Higher includes lower
|
||||
@@ -48,11 +49,13 @@ func (r gardenRole) String() string {
|
||||
|
||||
// GardenInput is the mutable field set for creating or updating a garden.
|
||||
type GardenInput struct {
|
||||
Name string
|
||||
WidthCM float64
|
||||
HeightCM float64
|
||||
UnitPref string
|
||||
Notes string
|
||||
Name string
|
||||
WidthCM float64
|
||||
HeightCM float64
|
||||
UnitPref string
|
||||
Notes string
|
||||
GridSizeCM float64
|
||||
SnapToGrid bool
|
||||
}
|
||||
|
||||
// requireGardenRole loads a garden and enforces that the actor holds at least
|
||||
@@ -163,7 +166,9 @@ func (s *Service) DeleteGarden(ctx context.Context, actorID, gardenID int64) err
|
||||
|
||||
// gardenFromInput validates and normalizes input into a domain.Garden (without
|
||||
// identity/ownership fields). With applyDefaults, absent dimensions/unit are
|
||||
// filled in (create); without it, every field must be supplied (update).
|
||||
// filled in (create); without it, dimensions and unit must be supplied (update).
|
||||
// The grid size is the exception: 0 always means "unset" and is defaulted on both
|
||||
// paths, so an older client that omits it can't wipe the column to an invalid 0.
|
||||
func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error) {
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" || len(name) > maxGardenNameLen {
|
||||
@@ -198,12 +203,26 @@ func gardenFromInput(in GardenInput, applyDefaults bool) (*domain.Garden, error)
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
// Grid spacing shares the garden dimension range [1 cm, 100 m]. 0 means
|
||||
// "unset" — defaulted (not just on create: an older client that omits it must
|
||||
// not wipe the column to an invalid 0), so a saved garden always has a usable
|
||||
// grid even if snapping is off.
|
||||
grid := in.GridSizeCM
|
||||
if grid == 0 {
|
||||
grid = defaultGardenGridCM
|
||||
}
|
||||
if !validDimensionCM(grid) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
return &domain.Garden{
|
||||
Name: name,
|
||||
WidthCM: width,
|
||||
HeightCM: height,
|
||||
UnitPref: unit,
|
||||
Notes: notes,
|
||||
Name: name,
|
||||
WidthCM: width,
|
||||
HeightCM: height,
|
||||
UnitPref: unit,
|
||||
Notes: notes,
|
||||
GridSizeCM: grid,
|
||||
SnapToGrid: in.SnapToGrid,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -10,29 +10,37 @@ import (
|
||||
)
|
||||
|
||||
// gardenColumns lists the gardens columns in the fixed order scanGarden expects.
|
||||
const gardenColumns = `id, owner_id, name, width_cm, height_cm, unit_pref, notes, version, created_at, updated_at`
|
||||
const gardenColumns = `id, owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid, version, created_at, updated_at`
|
||||
|
||||
func scanGarden(s scanner) (*domain.Garden, error) {
|
||||
var g domain.Garden
|
||||
var (
|
||||
g domain.Garden
|
||||
snap int64
|
||||
)
|
||||
if err := s.Scan(
|
||||
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM,
|
||||
&g.UnitPref, &g.Notes, &g.Version, &g.CreatedAt, &g.UpdatedAt,
|
||||
&g.UnitPref, &g.Notes, &g.GridSizeCM, &snap, &g.Version, &g.CreatedAt, &g.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SnapToGrid = snap != 0
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
// scanGardenWithRole reads a garden row plus a trailing my_role column into the
|
||||
// computed Garden.MyRole field (used by the actor-scoped list).
|
||||
func scanGardenWithRole(s scanner) (*domain.Garden, error) {
|
||||
var g domain.Garden
|
||||
var (
|
||||
g domain.Garden
|
||||
snap int64
|
||||
)
|
||||
if err := s.Scan(
|
||||
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM,
|
||||
&g.UnitPref, &g.Notes, &g.Version, &g.CreatedAt, &g.UpdatedAt, &g.MyRole,
|
||||
&g.UnitPref, &g.Notes, &g.GridSizeCM, &snap, &g.Version, &g.CreatedAt, &g.UpdatedAt, &g.MyRole,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
g.SnapToGrid = snap != 0
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
@@ -45,10 +53,10 @@ const maxGardensListed = 1000
|
||||
// set and validated by the service) and returns the stored row.
|
||||
func (d *DB) CreateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden, error) {
|
||||
created, err := scanGarden(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+gardenColumns,
|
||||
g.OwnerID, g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes,
|
||||
g.OwnerID, g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.GridSizeCM, boolToInt(g.SnapToGrid),
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert garden: %w", err)
|
||||
@@ -117,11 +125,12 @@ func (d *DB) UpdateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden
|
||||
updated, err := scanGarden(d.sql.QueryRowContext(ctx,
|
||||
`UPDATE gardens
|
||||
SET name = ?, width_cm = ?, height_cm = ?, unit_pref = ?, notes = ?,
|
||||
grid_size_cm = ?, snap_to_grid = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ? AND version = ?
|
||||
RETURNING `+gardenColumns,
|
||||
g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.ID, g.Version,
|
||||
g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.GridSizeCM, boolToInt(g.SnapToGrid), g.ID, g.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// No row matched: distinguish "gone" from "stale version".
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Grid + snap settings for the layout system.
|
||||
--
|
||||
-- Two independent grids:
|
||||
-- * gardens.grid_size_cm / snap_to_grid — the garden-scale grid the visual grid
|
||||
-- is drawn from and (when snap is on) objects snap to when placed/moved/resized.
|
||||
-- Defaults to 100 cm (1 m), matching the editor's previous fixed grid.
|
||||
-- * garden_objects.grid_size_cm / snap_to_grid — a per-object grid used inside a
|
||||
-- plantable bed: when snap is on, plants snap to it. Defaults to 30 cm (~1 ft),
|
||||
-- a practical bed cell.
|
||||
--
|
||||
-- snap defaults to off on both, so existing gardens keep free placement until the
|
||||
-- owner (garden grid) or an editor (bed grid) opts in.
|
||||
ALTER TABLE gardens ADD COLUMN grid_size_cm REAL NOT NULL DEFAULT 100;
|
||||
ALTER TABLE gardens ADD COLUMN snap_to_grid INTEGER NOT NULL DEFAULT 0 CHECK (snap_to_grid IN (0, 1));
|
||||
|
||||
ALTER TABLE garden_objects ADD COLUMN grid_size_cm REAL NOT NULL DEFAULT 30;
|
||||
ALTER TABLE garden_objects ADD COLUMN snap_to_grid INTEGER NOT NULL DEFAULT 0 CHECK (snap_to_grid IN (0, 1));
|
||||
@@ -11,21 +11,23 @@ import (
|
||||
|
||||
// objectColumns lists garden_objects columns in the order scanObject expects.
|
||||
const objectColumns = `id, garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, notes, version, created_at, updated_at`
|
||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes, version, created_at, updated_at`
|
||||
|
||||
func scanObject(s scanner) (*domain.GardenObject, error) {
|
||||
var (
|
||||
o domain.GardenObject
|
||||
plantable int64
|
||||
snap int64
|
||||
)
|
||||
if err := s.Scan(
|
||||
&o.ID, &o.GardenID, &o.Kind, &o.Name, &o.Shape, &o.Points,
|
||||
&o.XCM, &o.YCM, &o.WidthCM, &o.HeightCM, &o.RotationDeg, &o.ZIndex,
|
||||
&plantable, &o.Color, &o.Props, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
|
||||
&plantable, &o.Color, &o.Props, &o.GridSizeCM, &snap, &o.Notes, &o.Version, &o.CreatedAt, &o.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
o.Plantable = plantable != 0
|
||||
o.SnapToGrid = snap != 0
|
||||
return &o, nil
|
||||
}
|
||||
|
||||
@@ -35,11 +37,12 @@ func (d *DB) CreateObject(ctx context.Context, o *domain.GardenObject) (*domain.
|
||||
created, err := scanObject(d.sql.QueryRowContext(ctx,
|
||||
`INSERT INTO garden_objects
|
||||
(garden_id, kind, name, shape, points, x_cm, y_cm, width_cm, height_cm,
|
||||
rotation_deg, z_index, plantable, color, props, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
rotation_deg, z_index, plantable, color, props, grid_size_cm, snap_to_grid, notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING `+objectColumns,
|
||||
o.GardenID, o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("store: insert object: %w", err)
|
||||
@@ -95,13 +98,14 @@ func (d *DB) UpdateObject(ctx context.Context, o *domain.GardenObject) (*domain.
|
||||
`UPDATE garden_objects
|
||||
SET kind = ?, name = ?, shape = ?, points = ?, x_cm = ?, y_cm = ?,
|
||||
width_cm = ?, height_cm = ?, rotation_deg = ?, z_index = ?,
|
||||
plantable = ?, color = ?, props = ?, notes = ?,
|
||||
plantable = ?, color = ?, props = ?, grid_size_cm = ?, snap_to_grid = ?, notes = ?,
|
||||
version = version + 1,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||
WHERE id = ? AND version = ?
|
||||
RETURNING `+objectColumns,
|
||||
o.Kind, o.Name, o.Shape, o.Points, o.XCM, o.YCM, o.WidthCM, o.HeightCM,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props, o.Notes,
|
||||
o.RotationDeg, o.ZIndex, boolToInt(o.Plantable), o.Color, o.Props,
|
||||
o.GridSizeCM, boolToInt(o.SnapToGrid), o.Notes,
|
||||
o.ID, o.Version,
|
||||
))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -9,13 +9,17 @@ import { errorMessage } from '@/lib/api'
|
||||
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
|
||||
import {
|
||||
cmFromDisplay,
|
||||
cmFromSpacing,
|
||||
dimensionUnitLabel,
|
||||
displayFromCm,
|
||||
isValidDimensionCm,
|
||||
spacingFromCm,
|
||||
spacingUnitLabel,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
|
||||
const DEFAULT_METERS = 10 // matches the server's 10 m default
|
||||
const DEFAULT_GRID_CM = 100 // matches the server's 1 m grid default
|
||||
|
||||
const unitOptions = [
|
||||
{ value: 'metric', label: 'Metric (m)' },
|
||||
@@ -42,6 +46,10 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
||||
const [unit, setUnit] = useState<UnitPref>(garden?.unitPref ?? 'metric')
|
||||
const [width, setWidth] = useState(() => dimString(garden?.widthCm, garden?.unitPref ?? 'metric'))
|
||||
const [height, setHeight] = useState(() => dimString(garden?.heightCm, garden?.unitPref ?? 'metric'))
|
||||
const [gridSize, setGridSize] = useState(() =>
|
||||
String(spacingFromCm(garden?.gridSizeCm ?? DEFAULT_GRID_CM, garden?.unitPref ?? 'metric')),
|
||||
)
|
||||
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
|
||||
const [notes, setNotes] = useState(garden?.notes ?? '')
|
||||
const [version, setVersion] = useState(garden?.version ?? 0)
|
||||
const [conflict, setConflict] = useState<string | null>(null)
|
||||
@@ -52,8 +60,15 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
||||
const v = parseFloat(s)
|
||||
return Number.isFinite(v) ? String(displayFromCm(cmFromDisplay(v, unit), next)) : s
|
||||
}
|
||||
// The grid is entered at spacing scale (cm/in), so it converts with the
|
||||
// spacing helpers, not the dimension ones.
|
||||
const convertGrid = (s: string) => {
|
||||
const v = parseFloat(s)
|
||||
return Number.isFinite(v) ? String(spacingFromCm(cmFromSpacing(v, unit), next)) : s
|
||||
}
|
||||
setWidth(convert(width))
|
||||
setHeight(convert(height))
|
||||
setGridSize(convertGrid(gridSize))
|
||||
setUnit(next)
|
||||
}
|
||||
|
||||
@@ -75,8 +90,21 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
||||
setFormError('Width and height must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
const gridSizeCm = cmFromSpacing(parseFloat(gridSize), unit)
|
||||
if (!isValidDimensionCm(gridSizeCm)) {
|
||||
setFormError('Grid size must be between 1 cm and 100 m.')
|
||||
return
|
||||
}
|
||||
|
||||
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim() }
|
||||
const input = {
|
||||
name: name.trim(),
|
||||
widthCm,
|
||||
heightCm,
|
||||
unitPref: unit,
|
||||
notes: notes.trim(),
|
||||
gridSizeCm,
|
||||
snapToGrid,
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
@@ -95,6 +123,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
||||
setUnit(current.unitPref)
|
||||
setWidth(dimString(current.widthCm, current.unitPref))
|
||||
setHeight(dimString(current.heightCm, current.unitPref))
|
||||
setGridSize(String(spacingFromCm(current.gridSizeCm, current.unitPref)))
|
||||
setSnapToGrid(current.snapToGrid)
|
||||
setNotes(current.notes)
|
||||
setConflict('This garden changed elsewhere. The latest values are shown — review and save again.')
|
||||
return
|
||||
@@ -145,6 +175,30 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
|
||||
/>
|
||||
</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)} />
|
||||
|
||||
{formError && <Alert>{formError}</Alert>}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
@@ -13,15 +13,26 @@ import { useEditorStore } from './store'
|
||||
import { useViewport } from './useViewport'
|
||||
import type { EditorGarden, EditorObject } from './types'
|
||||
|
||||
const GRID_CM = 100 // 1 m grid
|
||||
const GRID_MIN_CELL_PX = 6
|
||||
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). */
|
||||
function objectRect(o: EditorObject): Rect {
|
||||
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/
|
||||
* 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)
|
||||
}, [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 sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
|
||||
@@ -117,7 +129,9 @@ export function GardenCanvas({
|
||||
const def = kindDef(armed)
|
||||
const rect = svgRef.current?.getBoundingClientRect()
|
||||
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(
|
||||
{ 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) },
|
||||
@@ -143,8 +157,14 @@ export function GardenCanvas({
|
||||
if (!rect) return
|
||||
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 x = Math.max(-focusedObject.widthCm / 2, Math.min(focusedObject.widthCm / 2, local.x))
|
||||
const y = Math.max(-focusedObject.heightCm / 2, Math.min(focusedObject.heightCm / 2, local.y))
|
||||
// snapLocalToBedGrid clamps to the bed either way; step 0 (snapping off) makes
|
||||
// it a pure clamp, so both paths go through one helper.
|
||||
const { x, y } = snapLocalToBedGrid(
|
||||
local,
|
||||
focusedObject.snapToGrid ? focusedObject.gridSizeCm : 0,
|
||||
focusedObject.widthCm / 2,
|
||||
focusedObject.heightCm / 2,
|
||||
)
|
||||
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||
// 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 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.
|
||||
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 (
|
||||
<div ref={containerRef} className="relative h-full w-full overflow-hidden rounded-xl border border-border bg-bg">
|
||||
<svg
|
||||
@@ -166,8 +200,8 @@ export function GardenCanvas({
|
||||
{gridOpacity > 0.005 && (
|
||||
<>
|
||||
<defs>
|
||||
<pattern id={gridId} width={GRID_CM} height={GRID_CM} 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" />
|
||||
<pattern id={gridId} width={gridCm} height={gridCm} patternUnits="userSpaceOnUse">
|
||||
<path d={`M ${gridCm} 0 L 0 0 0 ${gridCm}`} fill="none" stroke="#808080" strokeOpacity={gridOpacity} strokeWidth={1} vectorEffect="non-scaling-stroke" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x={0} y={0} width={garden.widthCm} height={garden.heightCm} fill={`url(#${gridId})`} />
|
||||
@@ -182,6 +216,17 @@ export function GardenCanvas({
|
||||
</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
|
||||
objects={rendered}
|
||||
plantings={renderedPlops}
|
||||
@@ -194,9 +239,24 @@ export function GardenCanvas({
|
||||
|
||||
{/* Edit handles are mounted only for editors/owners; viewers can still
|
||||
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 && (
|
||||
<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
|
||||
|
||||
@@ -5,9 +5,12 @@ import { TextArea } from '@/components/ui/TextArea'
|
||||
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
|
||||
import {
|
||||
cmFromDisplay,
|
||||
cmFromSpacing,
|
||||
dimensionUnitLabel,
|
||||
displayFromCm,
|
||||
MIN_DIMENSION_CM,
|
||||
spacingFromCm,
|
||||
spacingUnitLabel,
|
||||
type UnitPref,
|
||||
} from '@/lib/units'
|
||||
import { kindDef } from './kinds'
|
||||
@@ -48,6 +51,7 @@ export function Inspector({
|
||||
const [y, setY] = useState(String(displayFromCm(object.yCm, unit)))
|
||||
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
|
||||
const [color, setColor] = useState(object.color ?? DEFAULT_COLOR)
|
||||
const [gridSize, setGridSize] = useState(String(spacingFromCm(object.gridSizeCm, unit)))
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -64,7 +68,8 @@ export function Inspector({
|
||||
setY(String(displayFromCm(object.yCm, unit)))
|
||||
setRotation(String(Math.round(object.rotationDeg)))
|
||||
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'>>) => {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
return (
|
||||
@@ -218,14 +234,43 @@ export function Inspector({
|
||||
Plantable
|
||||
</label>
|
||||
|
||||
<TextArea
|
||||
label="Notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onBlur={() => notes !== object.notes && patch({ notes })}
|
||||
/>
|
||||
{/* 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
|
||||
label="Notes"
|
||||
name="notes"
|
||||
rows={2}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
onBlur={() => notes !== object.notes && patch({ notes })}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{!readOnly &&
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { EditorPlanting } from '@/lib/plantings'
|
||||
import { HANDLE_PX, MIN_RADIUS_CM, SELECT_COLOR, objectTransform } from './shared'
|
||||
@@ -20,11 +20,17 @@ export function PlopOverlay({
|
||||
object,
|
||||
gardenId,
|
||||
svgRef,
|
||||
snap,
|
||||
gridCm,
|
||||
}: {
|
||||
plop: EditorPlanting
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
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 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 = (
|
||||
e: ReactPointerEvent,
|
||||
onMove: (e: PointerEvent) => EditorPlanting,
|
||||
@@ -103,7 +104,10 @@ export function PlopOverlay({
|
||||
e,
|
||||
(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 }
|
||||
},
|
||||
(f) => ({ id: base.id, version: base.version, xCm: f.xCm, yCm: f.yCm }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { HANDLE_PX, SELECT_COLOR, objectTransform } from './shared'
|
||||
import { useEditorStore } from './store'
|
||||
@@ -27,10 +27,16 @@ export function SelectionOverlay({
|
||||
object,
|
||||
gardenId,
|
||||
svgRef,
|
||||
snap,
|
||||
gridCm,
|
||||
}: {
|
||||
object: EditorObject
|
||||
gardenId: number
|
||||
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 setObjectDragging = useEditorStore((s) => s.setObjectDragging)
|
||||
@@ -112,7 +118,9 @@ export function SelectionOverlay({
|
||||
e,
|
||||
(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 }),
|
||||
)
|
||||
@@ -128,8 +136,15 @@ export function SelectionOverlay({
|
||||
(ev) => {
|
||||
const world = pointerWorld(ev)
|
||||
const p = worldToLocal(world, center0, base.rotationDeg)
|
||||
const 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 newW = Math.max(MIN_OBJ_CM, Math.abs(p.x - oppositeLocal.x))
|
||||
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 newCenterLocal: Point = {
|
||||
x: (oppositeLocal.x + draggedLocal.x) / 2,
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface EditorObject {
|
||||
rotationDeg: number
|
||||
zIndex: number
|
||||
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
|
||||
plantable: boolean
|
||||
version: number
|
||||
@@ -29,4 +33,7 @@ export interface EditorGarden {
|
||||
widthCm: number
|
||||
heightCm: number
|
||||
unitPref: UnitPref
|
||||
// The garden-scale grid the canvas draws, and whether objects snap to it.
|
||||
gridSizeCm: number
|
||||
snapToGrid: boolean
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ export const gardenSchema = z.object({
|
||||
heightCm: z.number(),
|
||||
unitPref: unitPrefSchema,
|
||||
notes: z.string(),
|
||||
gridSizeCm: z.number(),
|
||||
snapToGrid: z.boolean(),
|
||||
version: z.number(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
@@ -55,6 +57,8 @@ export interface GardenInput {
|
||||
heightCm: number
|
||||
unitPref: UnitPref
|
||||
notes: string
|
||||
gridSizeCm: number
|
||||
snapToGrid: boolean
|
||||
}
|
||||
|
||||
export function useCreateGarden() {
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
clampScale,
|
||||
localToWorld,
|
||||
screenToWorld,
|
||||
snapLocalToBedGrid,
|
||||
snapPoint,
|
||||
snapValue,
|
||||
worldToLocal,
|
||||
worldToScreen,
|
||||
zoomToFitRect,
|
||||
@@ -91,3 +94,37 @@ describe('zoomToFitRect', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -68,6 +68,37 @@ export function clampScale(scale: number, min: number, max: number): number {
|
||||
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 {
|
||||
// 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. */
|
||||
export function lerp(a: number, b: number, t: number): number {
|
||||
return a + (b - a) * t
|
||||
|
||||
@@ -30,6 +30,8 @@ export const serverObjectSchema = z.object({
|
||||
plantable: z.boolean(),
|
||||
color: z.string().nullable().optional(),
|
||||
props: z.string().nullable().optional(),
|
||||
gridSizeCm: z.number(),
|
||||
snapToGrid: z.boolean(),
|
||||
notes: z.string(),
|
||||
version: z.number(),
|
||||
})
|
||||
@@ -59,6 +61,8 @@ export function toEditorObject(o: ServerObject): EditorObject {
|
||||
rotationDeg: o.rotationDeg,
|
||||
zIndex: o.zIndex,
|
||||
color: o.color ?? null,
|
||||
gridSizeCm: o.gridSizeCm,
|
||||
snapToGrid: o.snapToGrid,
|
||||
notes: o.notes,
|
||||
plantable: o.plantable,
|
||||
version: o.version,
|
||||
@@ -110,6 +114,8 @@ export interface ObjectCreate {
|
||||
zIndex?: number
|
||||
plantable?: boolean
|
||||
color?: string | null
|
||||
gridSizeCm?: number
|
||||
snapToGrid?: boolean
|
||||
}
|
||||
|
||||
export function useCreateObject(gardenId: number) {
|
||||
@@ -138,6 +144,8 @@ export interface ObjectPatch {
|
||||
zIndex?: number
|
||||
plantable?: boolean
|
||||
color?: string | null
|
||||
gridSizeCm?: number
|
||||
snapToGrid?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
@@ -392,6 +400,8 @@ function applyServerPatch(o: ServerObject, patch: ObjectPatch): ServerObject {
|
||||
...(patch.zIndex !== undefined ? { zIndex: patch.zIndex } : {}),
|
||||
...(patch.plantable !== undefined ? { plantable: patch.plantable } : {}),
|
||||
...(patch.color !== undefined ? { color: patch.color } : {}),
|
||||
...(patch.gridSizeCm !== undefined ? { gridSizeCm: patch.gridSizeCm } : {}),
|
||||
...(patch.snapToGrid !== undefined ? { snapToGrid: patch.snapToGrid } : {}),
|
||||
...(patch.notes !== undefined ? { notes: patch.notes } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +228,8 @@ export function GardenEditorPage() {
|
||||
widthCm: g.widthCm,
|
||||
heightCm: g.heightCm,
|
||||
unitPref: g.unitPref,
|
||||
gridSizeCm: g.gridSizeCm,
|
||||
snapToGrid: g.snapToGrid,
|
||||
}
|
||||
|
||||
const selectedObject = objects.find((o) => o.id === selectedId) ?? null
|
||||
|
||||
@@ -48,6 +48,8 @@ export function PublicGardenPage() {
|
||||
widthCm: g.widthCm,
|
||||
heightCm: g.heightCm,
|
||||
unitPref: g.unitPref,
|
||||
gridSizeCm: g.gridSizeCm,
|
||||
snapToGrid: g.snapToGrid,
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user