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). // Region is an area in an object's local frame: an axis-aligned rectangle, or a // circle when Circle is set. NamedRegion produces the common ones. type Region struct { MinX, MinY, MaxX, MaxY float64 // rectangle (also the circle's bounding box) Circle bool CX, CY, Radius float64 // used when Circle } // contains reports whether a local point lies in the region. func (r Region) contains(x, y float64) bool { if r.Circle { dx, dy := x-r.CX, y-r.CY return dx*dx+dy*dy <= r.Radius*r.Radius } return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY } // 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) { 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 #15's formula for a placed plop's radius: max(1.5×spacing, // 15cm). Reused here (don't duplicate the constant elsewhere). 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 } 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) existing, err := s.store.ListActivePlantingsForObject(ctx, objectID) if err != nil { return nil, err } today := s.now().UTC().Format(dateLayout) halfW, halfH := o.WidthCM/2, o.HeightCM/2 created := []domain.Planting{} for _, c := range hexCenters(region, radius) { // The center must be inside the object (region ⊆ bounds already, but guard). if math.Abs(c.x) > halfW || math.Abs(c.y) > halfH { continue } if coveredByExisting(c.x, c.y, radius, existing) { continue } p := &domain.Planting{ObjectID: objectID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today} stored, err := s.store.CreatePlanting(ctx, p) if err != nil { return nil, err } stored.DerivedCount = derivedCount(stored.RadiusCM, spacing) created = append(created, *stored) // Count what we just placed so later candidates in this same fill don't // stack on top of it. existing = append(existing, *stored) } 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.FillRegion(ctx, actorID, objectID, 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. 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" } }