Build image / build-and-push (push) Successful in 5s
- fillLoaded's doc listed what it does and omitted the non-finite-region rejection this PR added to it. - Trim the half-spacing rule's restatement in DESIGN.md to the decision and a pointer. The rule, the square-foot arithmetic and the failure mode are written out once, in hexCenters, rather than near-verbatim in four places. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
501 lines
19 KiB
Go
501 lines
19 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"math"
|
||
"strings"
|
||
|
||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||
)
|
||
|
||
// This file holds pansy's bulk, "natural-language-shaped" operations — the ones
|
||
// an agent drives ("fill the NE corner with garlic", "clear the bed"). They live
|
||
// on *Service like every other operation, so agent tools (internal/agent) and any
|
||
// future REST surface inherit the same ACL enforcement via objectForRole /
|
||
// requireGardenRole. Geometry is in each object's LOCAL frame (origin at the
|
||
// object's center, +x east, +y south, so -y is NORTH).
|
||
|
||
// maxFillPlops bounds a single FillRegion so a huge bed with tiny spacing can't
|
||
// generate a runaway number of inserts. A bed with thousands of plops is already
|
||
// far past any real garden; over the cap we refuse rather than silently truncate.
|
||
const maxFillPlops = 5000
|
||
|
||
// Region is an axis-aligned rectangle in an object's local frame. (Circle/polygon
|
||
// regions are post-v1, like polygon objects; NamedRegion produces only rects.)
|
||
type Region struct {
|
||
MinX, MinY, MaxX, MaxY float64
|
||
}
|
||
|
||
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
|
||
// so a fill can't plant outside the object it was aimed at. A region that misses
|
||
// the object entirely comes back empty — see empty().
|
||
func (r Region) clampTo(halfW, halfH float64) Region {
|
||
return Region{
|
||
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
|
||
MaxX: math.Min(r.MaxX, halfW), MaxY: math.Min(r.MaxY, halfH),
|
||
}
|
||
}
|
||
|
||
// empty reports whether the region encloses nothing.
|
||
//
|
||
// This exists because clampTo expresses "no overlap" by INVERTING the region —
|
||
// Max clamps below Min — rather than by zeroing it, which is not something a
|
||
// reader guesses. Naming it once here beats a bare `MaxX < MinX` at each place
|
||
// that has to care.
|
||
func (r Region) empty() bool {
|
||
return r.MaxX < r.MinX || r.MaxY < r.MinY
|
||
}
|
||
|
||
// rect builds a rectangular region.
|
||
func rect(minX, minY, maxX, maxY float64) Region {
|
||
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
|
||
}
|
||
|
||
// NamedRegion resolves a compass name to a Region in the object's local frame.
|
||
// Recognizes the quarter corners "nw|ne|sw|se", the halves
|
||
// "north|south|east|west" and their "top|bottom|left|right" synonyms, and "all".
|
||
// A trailing "corner"/"half" word is ignored ("NE corner", "south half"). North
|
||
// is -y (see the file header). Unknown names return ErrInvalidInput.
|
||
func NamedRegion(o *domain.GardenObject, name string) (Region, error) {
|
||
if o == nil {
|
||
return Region{}, domain.ErrInvalidInput
|
||
}
|
||
hw, hh := o.WidthCM/2, o.HeightCM/2
|
||
key := strings.ToLower(strings.TrimSpace(name))
|
||
key = strings.TrimSpace(strings.TrimSuffix(key, "corner"))
|
||
key = strings.TrimSpace(strings.TrimSuffix(key, "half"))
|
||
|
||
switch key {
|
||
case "all":
|
||
return rect(-hw, -hh, hw, hh), nil
|
||
case "north", "top":
|
||
return rect(-hw, -hh, hw, 0), nil
|
||
case "south", "bottom":
|
||
return rect(-hw, 0, hw, hh), nil
|
||
case "east", "right":
|
||
return rect(0, -hh, hw, hh), nil
|
||
case "west", "left":
|
||
return rect(-hw, -hh, 0, hh), nil
|
||
case "nw", "northwest":
|
||
return rect(-hw, -hh, 0, 0), nil
|
||
case "ne", "northeast":
|
||
return rect(0, -hh, hw, 0), nil
|
||
case "sw", "southwest":
|
||
return rect(-hw, 0, 0, hh), nil
|
||
case "se", "southeast":
|
||
return rect(0, 0, hw, hh), nil
|
||
default:
|
||
return Region{}, domain.ErrInvalidInput
|
||
}
|
||
}
|
||
|
||
// defaultPlopRadius is the radius a freshly-placed plop gets from its plant's
|
||
// spacing: max(1.5×spacing, 15cm) — matching the editor's placement default (#15).
|
||
func defaultPlopRadius(spacingCM float64) float64 {
|
||
return math.Max(1.5*spacingCM, 15)
|
||
}
|
||
|
||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
||
// radius pitch, centered in the region, and set in from each edge by the plop's
|
||
// radius less half a spacing — see hexCenters for why that half-spacing is what
|
||
// the edge is owed. A candidate is skipped when its plop would sit entirely
|
||
// inside an existing active plop (so re-filling doesn't stack duplicates).
|
||
// Returns the plops it created.
|
||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
||
}
|
||
|
||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||
// already loaded and authorized (roleEditor). It rejects a non-finite region,
|
||
// clamps the region to the object's bounds, refuses fills over maxFillPlops, and
|
||
// inserts the whole batch in one transaction rather than one round-trip per plop.
|
||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||
if !o.Plantable {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
plant, err := s.visiblePlant(ctx, actorID, plantID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
spacing := plant.SpacingCM
|
||
if spacingOverride != nil {
|
||
if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
spacing = *spacingOverride
|
||
}
|
||
radius := defaultPlopRadius(spacing)
|
||
if !isFinite(radius) || radius <= 0 {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
|
||
// A caller-supplied region is arbitrary floats, and non-finite ones survive
|
||
// everything downstream: clamping keeps them, the inverted-region guard can't
|
||
// see NaN (it compares false both ways), and fitAxis centres on them happily.
|
||
// Nothing corrupt reaches the table — SQLite stores NaN as NULL and the NOT
|
||
// NULL constraint refuses it — but the caller gets an opaque store error for
|
||
// NaN, and for +Inf a silent zero-plop success. Both are lies about what went
|
||
// wrong; say "bad input" here instead.
|
||
if !isFinite(region.MinX) || !isFinite(region.MinY) ||
|
||
!isFinite(region.MaxX) || !isFinite(region.MaxY) {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
|
||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||
centers, total := hexCenters(region, radius, spacing, maxFillPlops)
|
||
if total > maxFillPlops {
|
||
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
||
}
|
||
|
||
existing, err := s.store.ListActivePlantingsForObject(ctx, o.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
today := s.now().UTC().Format(dateLayout)
|
||
batch := make([]*domain.Planting, 0, len(centers))
|
||
for _, c := range centers {
|
||
if coveredByExisting(c.x, c.y, radius, existing) {
|
||
continue
|
||
}
|
||
p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
|
||
batch = append(batch, p)
|
||
existing = append(existing, *p) // so later candidates in THIS fill don't stack on it
|
||
}
|
||
created, err := s.store.CreatePlantings(ctx, batch)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// One record call with every plop, so a fill auto-scopes into ONE change set
|
||
// with N revisions — undoing a fill is one click, not N.
|
||
changes := make([]change, 0, len(created))
|
||
for i := range created {
|
||
changes = append(changes, changeCreate(domain.EntityPlanting, created[i].ID, &created[i]))
|
||
}
|
||
s.record(ctx, o.GardenID, actorID,
|
||
fmt.Sprintf("Planted %d %s in %s", len(created), plant.Name, objectLabel(o)), changes...)
|
||
for i := range created {
|
||
created[i].DerivedCount = derivedCount(created[i].RadiusCM, spacing)
|
||
}
|
||
return created, nil
|
||
}
|
||
|
||
type localPoint struct{ x, y float64 }
|
||
|
||
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3
|
||
// apart, alternate rows offset by half a pitch, at a 2×radius pitch. The lattice
|
||
// is CENTERED, so the leftover is shared between opposite edges instead of piling
|
||
// up against the far one.
|
||
//
|
||
// # How close to the edge the outer row goes
|
||
//
|
||
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
|
||
// soil, light and water. A bed edge is not a competitor, so the outer row only
|
||
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
|
||
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
|
||
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
|
||
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
|
||
//
|
||
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so
|
||
// three spacings across — and its plants sit out to its rim. So keeping the whole
|
||
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
|
||
// times what the rule allows. Instead the clump may hang over the edge by up to
|
||
// half a spacing, which puts its outermost plants exactly the half-spacing from
|
||
// the edge that the rule asks for. Overhang is capped there and nowhere near the
|
||
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
|
||
//
|
||
// Do not "simplify" this back to anchoring at the region's min corner. That is
|
||
// what #75 was: staggered rows start a full pitch in, and the leftover all lands
|
||
// on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||
//
|
||
// # Counting before building
|
||
//
|
||
// hexCenters returns the total alongside the points, and works that total out
|
||
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
||
// its whole lattice first just to be counted and thrown away. Over `limit` it
|
||
// returns (nil, total), so the caller can still refuse with the real number.
|
||
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
|
||
if radius <= 0 {
|
||
return nil, 0
|
||
}
|
||
// An empty region has no inside to plant. The old loop-until-past-MaxX form
|
||
// got this for free by never entering the loop; counting positions up front
|
||
// does not, and would site a plop off the bed.
|
||
if r.empty() {
|
||
return nil, 0
|
||
}
|
||
pitch := 2 * radius
|
||
rowH := pitch * math.Sqrt(3) / 2
|
||
|
||
// How far a clump's centre must stay inside the edge: its own radius, less the
|
||
// half-spacing of overhang the rule allows. Never negative, and never past the
|
||
// centre of the clump.
|
||
inset := math.Max(0, radius-math.Max(0, spacing)/2)
|
||
|
||
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
|
||
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
|
||
|
||
// Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
|
||
// over-reserve by ~12% — and, more to the point, allocating it is the thing we
|
||
// are trying to avoid when the answer is "too many".
|
||
staggered := cols
|
||
if cols > 1 {
|
||
staggered = cols - 1
|
||
}
|
||
total := (rows+1)/2*cols + rows/2*staggered
|
||
if total > limit {
|
||
return nil, total
|
||
}
|
||
|
||
pts := make([]localPoint, 0, total)
|
||
for row := 0; row < rows; row++ {
|
||
y := r.MinY + y0 + float64(row)*rowH
|
||
n, x := cols, r.MinX+x0
|
||
// The stagger falls out of centering: an offset row holds one fewer plop,
|
||
// and centering THAT run puts it exactly half a pitch off its neighbours.
|
||
// A single-column region has nothing to stagger against.
|
||
if row%2 == 1 && cols > 1 {
|
||
n, x = staggered, r.MinX+x0+pitch/2
|
||
}
|
||
for i := 0; i < n; i++ {
|
||
pts = append(pts, localPoint{x + float64(i)*pitch, y})
|
||
}
|
||
}
|
||
return pts, total
|
||
}
|
||
|
||
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
|
||
// at least `inset` from each end, and the offset from the span's start that
|
||
// centers them — so the leftover is split between the two edges rather than all
|
||
// landing on the far one.
|
||
//
|
||
// A span too small to hold even one position at that inset still gets one, in the
|
||
// middle: filling a bed narrower than a single plop with one plop is a better
|
||
// answer than refusing to plant it.
|
||
//
|
||
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
|
||
// caller, returns early unless radius > 0, which makes both steps it passes
|
||
// positive. It stays because dividing by a non-positive step yields ±Inf and then
|
||
// a garbage int conversion, and a helper this small should not require reading
|
||
// its caller to know it is safe. Deliberate, not an oversight.
|
||
func fitAxis(length, step, inset float64) (n int, start float64) {
|
||
if step <= 0 || length < 2*inset {
|
||
return 1, length / 2
|
||
}
|
||
// The epsilon keeps an exact fit from being lost to floating point — a 60cm
|
||
// span at a 30cm step should give 2 positions, not 1 because the division
|
||
// landed on 0.9999999.
|
||
const eps = 1e-9
|
||
n = int(math.Floor((length-2*inset)/step+eps)) + 1
|
||
return n, (length - float64(n-1)*step) / 2
|
||
}
|
||
|
||
// coveredByExisting reports whether a new plop (center, radius) would sit
|
||
// entirely inside some existing active plop.
|
||
func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
|
||
for _, e := range existing {
|
||
if math.Hypot(x-e.XCM, y-e.YCM)+radius <= e.RadiusCM {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
|
||
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
||
// hold the object's geometry. It resolves the name against the object, then fills.
|
||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
region, err := NamedRegion(o, regionName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
|
||
}
|
||
|
||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||
// UPDATE), returning how many were cleared. Distinct from deleting the object.
|
||
// Unlike FillRegion it does NOT require the object be plantable: an object toggled
|
||
// non-plantable after it was planted must still be clearable (you can always
|
||
// remove existing plops, only not add new ones).
|
||
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
|
||
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
// Snapshot the rows the bulk UPDATE is about to touch, since it reports only a
|
||
// count — then clear exactly those ids. Clearing "every active plop" instead
|
||
// would let a plop created between this read and the UPDATE be removed with no
|
||
// revision recorded: cleared, with no way to undo it.
|
||
before, err := s.store.ListActivePlantingsForObject(ctx, objectID)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
ids := make([]int64, 0, len(before))
|
||
for i := range before {
|
||
ids = append(ids, before[i].ID)
|
||
}
|
||
today := s.now().UTC().Format(dateLayout)
|
||
n, err := s.store.ClearObjectPlantings(ctx, objectID, today, ids)
|
||
if err != nil || n == 0 {
|
||
return n, err
|
||
}
|
||
|
||
// From here the clear HAS happened. A failure to build the history entry must
|
||
// not be reported as a failed clear — the caller would retry an operation that
|
||
// already applied. Log the gap and report success, matching how record()
|
||
// treats its own write failures.
|
||
after, err := s.store.ListPlantingsForObject(ctx, objectID)
|
||
if err != nil {
|
||
slog.Error("service: clear succeeded but history could not be recorded",
|
||
"error", err, "object", objectID, "cleared", n)
|
||
return n, nil
|
||
}
|
||
afterByID := make(map[int64]*domain.Planting, len(after))
|
||
for i := range after {
|
||
afterByID[after[i].ID] = &after[i]
|
||
}
|
||
changes := make([]change, 0, len(before))
|
||
for i := range before {
|
||
b := before[i]
|
||
a, ok := afterByID[b.ID]
|
||
if !ok {
|
||
continue // deleted outright between the two reads; nothing coherent to record
|
||
}
|
||
changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a))
|
||
}
|
||
s.record(ctx, g.ID, actorID, fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n), changes...)
|
||
return n, nil
|
||
}
|
||
|
||
// DescribeResult is a structured summary of a garden for prompting an agent.
|
||
type DescribeResult struct {
|
||
GardenID int64 `json:"gardenId"`
|
||
Name string `json:"name"`
|
||
WidthCM float64 `json:"widthCm"`
|
||
HeightCM float64 `json:"heightCm"`
|
||
UnitPref string `json:"unitPref"`
|
||
Objects []DescribeObject `json:"objects"`
|
||
}
|
||
|
||
// DescribeObject is one object plus its active plantings, for DescribeResult.
|
||
// Version is included so an agent can move/edit the object (the mutation guard).
|
||
type DescribeObject struct {
|
||
ID int64 `json:"id"`
|
||
Kind string `json:"kind"`
|
||
Name string `json:"name"`
|
||
Shape string `json:"shape"`
|
||
WidthCM float64 `json:"widthCm"`
|
||
HeightCM float64 `json:"heightCm"`
|
||
XCM float64 `json:"xCm"`
|
||
YCM float64 `json:"yCm"`
|
||
RotationDeg float64 `json:"rotationDeg"`
|
||
Plantable bool `json:"plantable"`
|
||
Version int64 `json:"version"`
|
||
Plantings []DescribePlanting `json:"plantings"`
|
||
}
|
||
|
||
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
|
||
type DescribePlanting struct {
|
||
PlantID int64 `json:"plantId"`
|
||
Plant string `json:"plant"`
|
||
Count int `json:"count"`
|
||
Location string `json:"location"`
|
||
RadiusCM float64 `json:"radiusCm"`
|
||
}
|
||
|
||
// DescribeGarden returns a structured summary — dimensions, objects, and each
|
||
// object's active plantings (plant, effective count, rough location) — for a
|
||
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
|
||
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
|
||
full, err := s.GardenFull(ctx, actorID, gardenID, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
plantByID := make(map[int64]domain.Plant, len(full.Plants))
|
||
for _, p := range full.Plants {
|
||
plantByID[p.ID] = p
|
||
}
|
||
plopsByObject := make(map[int64][]domain.Planting)
|
||
for _, pl := range full.Plantings {
|
||
plopsByObject[pl.ObjectID] = append(plopsByObject[pl.ObjectID], pl)
|
||
}
|
||
|
||
res := &DescribeResult{
|
||
GardenID: full.Garden.ID,
|
||
Name: full.Garden.Name,
|
||
WidthCM: full.Garden.WidthCM,
|
||
HeightCM: full.Garden.HeightCM,
|
||
UnitPref: full.Garden.UnitPref,
|
||
Objects: make([]DescribeObject, 0, len(full.Objects)),
|
||
}
|
||
for _, o := range full.Objects {
|
||
do := DescribeObject{
|
||
ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape,
|
||
WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM,
|
||
RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version,
|
||
Plantings: []DescribePlanting{},
|
||
}
|
||
for _, pl := range plopsByObject[o.ID] {
|
||
count := pl.DerivedCount
|
||
if pl.Count != nil {
|
||
count = *pl.Count
|
||
}
|
||
do.Plantings = append(do.Plantings, DescribePlanting{
|
||
PlantID: pl.PlantID,
|
||
Plant: plantByID[pl.PlantID].Name,
|
||
Count: count,
|
||
Location: describeLocation(pl.XCM, pl.YCM),
|
||
RadiusCM: pl.RadiusCM,
|
||
})
|
||
}
|
||
res.Objects = append(res.Objects, do)
|
||
}
|
||
return res, nil
|
||
}
|
||
|
||
// describeLocation reverse-maps a local point to a rough compass location — the
|
||
// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center").
|
||
func describeLocation(x, y float64) string {
|
||
const eps = 1e-6
|
||
ns := ""
|
||
switch {
|
||
case y < -eps:
|
||
ns = "N"
|
||
case y > eps:
|
||
ns = "S"
|
||
}
|
||
ew := ""
|
||
switch {
|
||
case x < -eps:
|
||
ew = "W"
|
||
case x > eps:
|
||
ew = "E"
|
||
}
|
||
switch {
|
||
case ns == "" && ew == "":
|
||
return "center"
|
||
case ns != "" && ew != "":
|
||
return ns + ew + " corner"
|
||
case ns == "N":
|
||
return "north"
|
||
case ns == "S":
|
||
return "south"
|
||
case ew == "E":
|
||
return "east"
|
||
default:
|
||
return "west"
|
||
}
|
||
}
|