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. // // Note this INVERTS (Max ends up below Min) rather than empties a region that // misses the object altogether — hexCenters treats that as "nothing to plant". 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), } } // 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 := hexCenters(region, radius, spacing) if len(centers) > 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. func hexCenters(r Region, radius, spacing float64) []localPoint { if radius <= 0 { return nil } // clampTo INVERTS a region that lies wholly outside the object (Max clamps // below Min), and an inverted 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.MaxX < r.MinX || r.MaxY < r.MinY { return nil } 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) pts := make([]localPoint, 0, rows*cols) 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 = cols-1, r.MinX+x0+radius } for i := 0; i < n; i++ { pts = append(pts, localPoint{x + float64(i)*pitch, y}) } } return pts } // 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" } }