Steve chose option 3: keep clumps as the default primitive, add a grid/rows fill mode, so sketching and planning are different operations with different outputs rather than one model forced to be both. A plop is a CLUMP, not a plant — great for "a few plops of garlic in a corner", useless for drawing a plantable 8-rows-of-garlic bed (that came out as ~15 blobs, #77). FillLayout selects what a fill packs: - clump (default, unchanged): radius 1.5×spacing, ~7 plants per plop. - grid: radius spacing/2, pitch = spacing, ONE plant per plop — rows you could actually plant from. The geometry is the SAME hexCenters lattice and the SAME #75 half-spacing edge rule; only the radius→spacing relationship differs (plopRadiusFor). Grid keeps no 15cm floor — its whole point is true spacing — while clump keeps it so a tiny-spacing plant doesn't make invisible clumps. Threaded through FillRegion/FillNamedRegion (empty layout = clump, so existing callers are unchanged; unknown layout = ErrInvalidInput), the REST /fill endpoint (`layout`), and the agent's fill_region tool (`mode`, enum clump|grid), so "plant the bed in rows" works. Tests: grid produces many more, single-plant plops than clump on the same bed (radius spacing/2, derived count 1); unknown layout is refused at both the service and the API. maxFillPlops still caps a grid fill of a huge bed. No frontend fill affordance exists yet (fill is agent-only in the UI; the fill UI was deferred in #82), so the mode toggle rides along when that's built — noted. Docs: DESIGN placement-model decision. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
121 lines
4.6 KiB
Go
121 lines
4.6 KiB
Go
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})
|
|
}
|