Build image / build-and-push (push) Successful in 7s
- hexCenters now derives its exact point count BEFORE building anything and returns it alongside the points, refusing over the cap without allocating. Previously it materialised the whole lattice and fillLoaded checked len() afterwards — so the "too large" path paid for the thing it was rejecting. This also makes the preallocation exact, which subsumes the earlier over-allocation finding I'd declined: staggered rows hold cols-1, so rows*cols over-reserved by ~12%. - Region.empty() names the invariant that clampTo expresses "no overlap" by INVERTING the region rather than zeroing it. A bare `MaxX < MinX` at each call site was spreading a non-obvious convention across three functions. The count is now load-bearing (it gates the cap), so the test asserts it matches what actually gets built. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
492 lines
19 KiB
Go
492 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 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.
|
||
// It reports 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 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+radius
|
||
}
|
||
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.
|
||
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"
|
||
}
|
||
}
|