Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
349 lines
12 KiB
Go
349 lines
12 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"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
|
||
}
|
||
|
||
// contains reports whether a local point lies in the region.
|
||
func (r Region) contains(x, y float64) bool {
|
||
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
|
||
}
|
||
|
||
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
|
||
// so an oversized caller-supplied region can't make hexCenters loop forever.
|
||
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, kept where the center is inside the region. 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
|
||
}
|
||
|
||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||
centers := hexCenters(region, radius)
|
||
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
|
||
}
|
||
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 whose center lies in the region.
|
||
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
|
||
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
|
||
// radius inside the region's min corner so the first plop sits inside it.
|
||
func hexCenters(r Region, radius float64) []localPoint {
|
||
if radius <= 0 {
|
||
return nil
|
||
}
|
||
pitch := 2 * radius
|
||
rowH := pitch * math.Sqrt(3) / 2
|
||
const eps = 1e-6
|
||
var pts []localPoint
|
||
row := 0
|
||
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
|
||
xStart := r.MinX + radius
|
||
if row%2 == 1 {
|
||
xStart += radius
|
||
}
|
||
for x := xStart; x <= r.MaxX+eps; x += pitch {
|
||
if r.contains(x, y) {
|
||
pts = append(pts, localPoint{x, y})
|
||
}
|
||
}
|
||
row++
|
||
}
|
||
return pts
|
||
}
|
||
|
||
// 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) {
|
||
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor); err != nil {
|
||
return 0, err
|
||
}
|
||
today := s.now().UTC().Format(dateLayout)
|
||
return s.store.ClearObjectPlantings(ctx, objectID, today)
|
||
}
|
||
|
||
// 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)
|
||
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"
|
||
}
|
||
}
|