package api import ( "net/http" "github.com/gin-gonic/gin" "gitea.stevedudenhoeffer.com/steve/pansy/internal/domain" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service" ) // Bulk operations on a plantable object (#82): fill a region with one plant, and // clear everything out of it. // // These were reachable only through the agent toolbox until now, which meant the // most valuable bulk operation in a garden planner — and the one carrying the // most carefully reasoned geometry in the codebase — did not exist at all on an // instance with no model configured. They are thin adapters over the same // service methods `internal/agent/tools.go` calls, so the permission checks and // the one-change-set-per-operation guarantee come along unchanged. // fillRect is an explicit rectangle in the object's local frame, the alternative // to a compass name. A named type (not an inline anonymous struct) to match the // rest of internal/api and so it can carry its own validity check. type fillRect struct { MinX float64 `json:"minXCm"` MinY float64 `json:"minYCm"` MaxX float64 `json:"maxXCm"` MaxY float64 `json:"maxYCm"` } // degenerate reports whether the rect encloses no area. Such a rect (including // the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through // and plant a single plop at the object's centre — a surprising result for what // is really malformed input. func (r fillRect) degenerate() bool { return r.MaxX <= r.MinX || r.MaxY <= r.MinY } // objectFillRequest is the body for POST /objects/:id/fill. // // A region is given EITHER by compass name ("ne", "south half", "all") or as an // explicit rect in the object's local frame. The named form is what a person // means and what the agent uses; the rect is for a future drag-a-box affordance. // Exactly one must be supplied — accepting both and silently preferring one // would make a client bug look like a geometry bug. type objectFillRequest struct { PlantID int64 `json:"plantId" binding:"required"` Region string `json:"region"` Rect *fillRect `json:"rect"` // SpacingOverrideCM plants tighter or looser than the plant's mature spacing // without editing the catalog entry. SpacingOverrideCM *float64 `json:"spacingOverrideCm"` // Layout is "clump" (default; fat clumps for a quick sketch) or "grid" // (individual plants in rows at true spacing). Empty = clump. An unknown value // is refused by the service (#77). Layout string `json:"layout"` } func (h *handlers) fillObject(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } var req objectFillRequest if err := c.ShouldBindJSON(&req); err != nil { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required") return } named, hasRect := req.Region != "", req.Rect != nil if named == hasRect { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", `supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`) return } actor := mustActor(c).ID var ( created []domain.Planting err error ) if rect := req.Rect; rect != nil { // Reject a zero-area rect here rather than let it plant one stray plop. // (Binding the pointer to `rect` also keeps the deref visibly guarded, // instead of reading req.Rect.MinX under an invariant from a line above.) if rect.degenerate() { writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area") return } region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY} created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout)) } else { created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout)) } if err != nil { writeServiceError(c, err) return } // 200, not 201: a fill can legitimately create nothing (the region is already // planted), and there is no single resource to point a Location at. c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)}) } // clearObject soft-removes every active plop in an object. // // Distinct from deleting the object, and — unlike the client-side loop this // replaces — it lands as ONE change set, so undoing a cleared bed is one click // rather than one per plop. func (h *handlers) clearObject(c *gin.Context) { id, ok := parseIDParam(c, "id") if !ok { return } n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id) if err != nil { writeServiceError(c, err) return } c.JSON(http.StatusOK, gin.H{"cleared": n}) }