package service import ( "context" "errors" "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) } // FillLayout selects what a fill packs (#77). // // A plop is a CLUMP, not a plant, and that abstraction is the right primitive for // SKETCHING — "a few plops of garlic in one corner" — but it can't draw a real // planting: a filled 4×8ft bed comes out as ~15 blobs, not 8 rows of garlic. So // filling is now two operations. FillClump (the default, unchanged) drops fat // clumps for quick coverage; FillGrid lays out individual plants at true spacing, // producing a layout you could actually plant from. type FillLayout string const ( // FillClump packs fat clumps (radius 1.5×spacing). Each plop is ~7 plants. FillClump FillLayout = "clump" // FillGrid packs one plant per plop at true spacing (radius spacing/2, pitch // = spacing). A bed becomes rows of individual plants. FillGrid FillLayout = "grid" ) // plopRadiusFor is the plop radius a fill uses, given the plant's spacing and the // layout. Grid mode is a plain spacing/2 (so the pitch is one spacing and each // plop's derived count is 1); clump mode keeps the 15cm floor that stops a // tiny-spacing plant from making invisibly small clumps — a floor grid mode // doesn't want, since its whole point is true spacing. func plopRadiusFor(spacingCM float64, layout FillLayout) float64 { if layout == FillGrid { return spacingCM / 2 } return defaultPlopRadius(spacingCM) } // edgeInset is how far a plop's CENTRE must stay inside the region edge. It // differs by layout because the half-spacing rule is about where the PLANT lands, // and the plant sits in a different place within the plop. // // Spacing is a constraint between neighbouring plants competing for the same soil, // light and water; a bed edge is nobody's neighbour, so the outer plant owes it // only HALF the spacing — the half it would otherwise share. That is the // square-foot-chart arithmetic: garlic at 9-per-square sits 2" from the frame, not // 6". // // - Grid: one plant, at the plop's centre. Put that centre a half-spacing in and // the outer row lands exactly where the rule wants it — inset = spacing/2. // - Clump: a fat plop (radius 1.5×spacing) whose plants fill out to its RIM. // Insetting the whole circle would push the outer row a full 1.5 spacings in, // three times the rule. Instead the clump may hang over by a half-spacing (rim // at spacing/2 past the edge), landing its outermost plants that same // half-spacing in — inset = radius − spacing/2. A grid plop reusing THAT // formula would inset by radius − spacing/2 = 0 and plant flush on the edge, // which is the bug this split fixes. func edgeInset(radius, spacing float64, layout FillLayout) float64 { half := math.Max(0, spacing) / 2 if layout == FillGrid { return half } return math.Max(0, radius-half) } // validFillLayout normalizes a layout: empty defaults to clump (so existing // callers are unchanged), a known value passes, anything else is rejected. func validFillLayout(l FillLayout) (FillLayout, bool) { switch l { case "", FillClump: return FillClump, true case FillGrid: return FillGrid, true default: return "", false } } // FillRegion lays a field of plops of one plant across a region of a plantable // object the actor can edit. The layout picks the primitive: FillClump drops fat // clumps for quick sketching, FillGrid lays out individual plants at true spacing // (see FillLayout). Plop radius comes from the plant's spacing (or spacingOverride) // via plopRadiusFor; centers sit on a centered hex lattice at 2×radius pitch, set // in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half- // spacing for a clump (see edgeInset for the why). A candidate is skipped when its // plop would sit entirely inside an existing active plop (so re-filling doesn't // stack duplicates). Every plop is dated plantedAt (YYYY-MM-DD), or UTC today // when nil — the UI always sends its local day, so the default is for API and // agent callers. Returns the plops it created. func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) { return s.Fill(ctx, actorID, objectID, FillSpec{ Region: region, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt, }) } // FillSpec is everything a fill needs besides the object it fills: where (a // compass RegionName, or an explicit Region in the object's local frame when the // name is empty), what, and how. type FillSpec struct { // RegionName is a compass name for NamedRegion ("ne", "south half", "all"). // When it is empty, Region is used as given. RegionName string Region Region PlantID int64 // SpacingOverride replaces the plant's own spacing for this fill, in cm. SpacingOverride *float64 // Layout is clump (the default) or grid; see FillLayout. Layout FillLayout // PlantedAt dates every plop the fill makes (YYYY-MM-DD). nil means the // service's UTC today; a caller that knows the person's local day sends it. PlantedAt *string // SeedLotID attributes every plop to one of the actor's seed lots, so the lot // can report what it has left. Optional. SeedLotID *int64 } // Fill plants one plant across part of an object the actor can edit, per spec. // FillRegion and FillNamedRegion are the two older spellings of it. func (s *Service) Fill(ctx context.Context, actorID, objectID int64, spec FillSpec) ([]domain.Planting, error) { o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if err != nil { return nil, err } region := spec.Region if strings.TrimSpace(spec.RegionName) != "" { if region, err = NamedRegion(o, spec.RegionName); err != nil { return nil, err } } else if !(region.MinX < region.MaxX && region.MinY < region.MaxY) { // A zero or inverted rectangle is a caller that said nothing about where // — not a request for the one plop hexCenters would put at its middle. return nil, fmt.Errorf("%w: the fill rectangle is empty", domain.ErrInvalidInput) } return s.fillLoaded(ctx, actorID, o, region, spec) } // fillLoaded is the body of Fill given an object already loaded and authorized // (roleEditor) and its region resolved. It validates the layout, 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, spec FillSpec) ([]domain.Planting, error) { if !o.Plantable { return nil, domain.ErrInvalidInput } if !validDatePtr(spec.PlantedAt) { return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput) } layout, ok := validFillLayout(spec.Layout) if !ok { return nil, domain.ErrInvalidInput } plant, err := s.visiblePlant(ctx, actorID, spec.PlantID) if err != nil { return nil, err } // Checked before anything is planted, as CreatePlanting does: a lot of the // wrong variety, or someone else's, refuses the whole fill. if err := s.checkSeedLotForPlanting(ctx, actorID, spec.SeedLotID, spec.PlantID); err != nil { return nil, err } spacing := plant.SpacingCM if spec.SpacingOverride != nil { if !isFinite(*spec.SpacingOverride) || *spec.SpacingOverride < minPlantSpacingCM || *spec.SpacingOverride > maxPlantSpacingCM { return nil, domain.ErrInvalidInput } spacing = *spec.SpacingOverride } radius := plopRadiusFor(spacing, layout) 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, edgeInset(radius, spacing, layout), 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 } plantedOn := s.now().UTC().Format(dateLayout) if spec.PlantedAt != nil { plantedOn = *spec.PlantedAt } batch := make([]*domain.Planting, 0, len(centers)) // Only the plops that were ALREADY here can cover a candidate: every plop this // fill makes shares one radius and sits on a distinct lattice point, and a plop // is "covered" only when it lies entirely inside another — impossible between // two equal-radius circles at different centres. So skip against `existing` as // loaded and don't grow it per plop, which made an empty-bed grid fill's check // needlessly quadratic. for _, c := range centers { if coveredByExisting(c.x, c.y, radius, existing) { continue } batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: spec.PlantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn, SeedLotID: spec.SeedLotID}) } 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 // // The caller passes `inset`: the margin the outer row keeps from every edge. It // encodes the half-spacing rule (spacing is owed between neighbouring plants, and // a bed edge is nobody's neighbour) and differs by layout — see edgeInset, which // derives it. hexCenters just honours it on all four sides. // // Do not "simplify" the centering 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, inset 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 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, layout FillLayout, plantedAt *string) ([]domain.Planting, error) { if strings.TrimSpace(regionName) == "" { // Fill would read a blank name as "use the (zero) Region" and plant // nothing; here a blank name is the caller's mistake, as it always was. return nil, domain.ErrInvalidInput } return s.Fill(ctx, actorID, objectID, FillSpec{ RegionName: regionName, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt, }) } // 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) { return s.ClearPlantings(ctx, actorID, objectID, ClearOptions{}) } // ClearOptions narrows ClearPlantings. type ClearOptions struct { // PlantID limits the clear to one plant — "pull the beets out, leave the // garlic" — nil clears every plant. PlantID *int64 // RemovedAt is the removal date (YYYY-MM-DD). nil means the service's UTC // today; a caller that knows the person's local day sends it. RemovedAt *string } // ClearPlantings is ClearObject with options: all of an object's active plops, or // only one plant's. The whole clear is one change set either way. func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, opts ClearOptions) (int, error) { o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if err != nil { return 0, err } if !validDatePtr(opts.RemovedAt) { return 0, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput) } // 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 } what := "" // names the plant in the summary when the clear is for one plant if opts.PlantID != nil { only := make([]domain.Planting, 0, len(before)) for i := range before { if before[i].PlantID == *opts.PlantID { only = append(only, before[i]) } } before = only // The summary is read by a person, so name the plant, not its id. A plant // that no longer exists just goes unnamed. if plant, err := s.store.GetPlant(ctx, *opts.PlantID); err == nil { what = plant.Name } else if !errors.Is(err, domain.ErrNotFound) { return 0, err } } ids := make([]int64, 0, len(before)) for i := range before { ids = append(ids, before[i].ID) } removedOn := s.now().UTC().Format(dateLayout) if opts.RemovedAt != nil { removedOn = *opts.RemovedAt } n, err := s.store.ClearObjectPlantings(ctx, objectID, removedOn, 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)) } summary := fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n) if opts.PlantID != nil { if what == "" { what = "plantings" } summary = fmt.Sprintf("Removed %s from %s (%d plantings)", what, objectLabel(o), n) } s.record(ctx, g.ID, actorID, summary, 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 grouped by plant, 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 []DescribeGroup `json:"plantings"` } // maxListedPlops is the largest group DescribeGroup.Each spells out plop by plop. // Up to it, a group is a handful of placements someone may address one at a time // ("pull the basil out of the corner"). Past it — a grid-filled bed is hundreds — // the ids are noise that costs a model more than it informs, and the group is // addressed as a whole (ClearPlantings) or listed on demand (ListObjectPlantings). // The live instance's first describe of a grid-filled garden was ~450 plop // entries, on every turn. const maxListedPlops = 8 // DescribeGroup summarizes every active plop of one plant in an object — the // unit a person talks about ("the cucumbers in the west bed") — with the count, // a rough location, and when it went in. type DescribeGroup struct { PlantID int64 `json:"plantId"` Plant string `json:"plant"` // Plops is how many placements make up the group; Plants the effective plant // count across them (explicit counts, else derived from area and spacing). Plops int `json:"plops"` Plants int `json:"plants"` // Where is a rough location: a compass region when the group sits in one // ("north half", "NE corner"), "throughout" when it spans the object, a short // list of locations, or — for anything else — its bounding box in local cm. Where string `json:"where"` // PlantedAt is the planting date, or "first…last" when the plops differ. PlantedAt string `json:"plantedAt,omitempty"` // DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt, // enough to say when the harvest is due. DaysToMaturity *int `json:"daysToMaturity,omitempty"` // Each lists the plops individually (id, version, location) only when the // group has at most maxListedPlops of them. Each []DescribePlanting `json:"each,omitempty"` } // DescribePlanting is one plop with a rough compass location. ID + Version let // an agent address a single plop — remove it or move it — the same way // DescribeObject.Version lets it edit an object. type DescribePlanting struct { ID int64 `json:"id"` Version int64 `json:"version"` PlantID int64 `json:"plantId"` Plant string `json:"plant"` Count int `json:"count"` Location string `json:"location"` RadiusCM float64 `json:"radiusCm"` PlantedAt string `json:"plantedAt,omitempty"` } // DescribeGarden returns a structured summary — dimensions, objects, and each // object's active plantings grouped by plant (count, rough location, planting // date) — 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 i := range full.Objects { o := &full.Objects[i] res.Objects = append(res.Objects, 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: describeGroups(o, plopsByObject[o.ID], plantByID), }) } return res, nil } // ListObjectPlantings lists an object's active plops one by one — the ids that // DescribeGarden summarizes away for a large group. plantID narrows it to one // plant. Viewer role, like DescribeGarden. func (s *Service) ListObjectPlantings(ctx context.Context, actorID, objectID int64, plantID *int64) ([]DescribePlanting, error) { if _, _, err := s.objectForRole(ctx, actorID, objectID, roleViewer); err != nil { return nil, err } plops, err := s.store.ListActivePlantingsForObject(ctx, objectID) if err != nil { return nil, err } // Plants looked up by id, not through the actor's catalog: a plop in a shared // garden may be of the owner's private variety, and it still has a name. plants := map[int64]domain.Plant{} out := make([]DescribePlanting, 0, len(plops)) for _, pl := range plops { if plantID != nil && pl.PlantID != *plantID { continue } plant, ok := plants[pl.PlantID] if !ok { p, err := s.store.GetPlant(ctx, pl.PlantID) if err != nil && !errors.Is(err, domain.ErrNotFound) { return nil, err } if p != nil { plant = *p } plants[pl.PlantID] = plant // a plant that no longer exists lists unnamed, not as an error } pl.DerivedCount = derivedCount(pl.RadiusCM, plant.SpacingCM) out = append(out, describePlanting(pl, plant.Name)) } return out, nil } // describeGroups groups an object's active plops by plant, in the order the // plants first appear, so the same garden always describes the same way. func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID map[int64]domain.Plant) []DescribeGroup { byPlant := map[int64][]domain.Planting{} var order []int64 for _, pl := range plops { if _, seen := byPlant[pl.PlantID]; !seen { order = append(order, pl.PlantID) } byPlant[pl.PlantID] = append(byPlant[pl.PlantID], pl) } groups := make([]DescribeGroup, 0, len(order)) for _, pid := range order { members := byPlant[pid] plant := plantByID[pid] g := DescribeGroup{ PlantID: pid, Plant: plant.Name, Plops: len(members), Where: summarizeWhere(o, members), PlantedAt: dateRange(members), DaysToMaturity: plant.DaysToMaturity, } for _, pl := range members { g.Plants += effectiveCount(pl) } if len(members) <= maxListedPlops { g.Each = make([]DescribePlanting, 0, len(members)) for _, pl := range members { g.Each = append(g.Each, describePlanting(pl, plant.Name)) } } groups = append(groups, g) } return groups } func describePlanting(pl domain.Planting, plantName string) DescribePlanting { d := DescribePlanting{ ID: pl.ID, Version: pl.Version, PlantID: pl.PlantID, Plant: plantName, Count: effectiveCount(pl), Location: describeLocation(pl.XCM, pl.YCM), RadiusCM: pl.RadiusCM, } if pl.PlantedAt != nil { d.PlantedAt = *pl.PlantedAt } return d } // effectiveCount is the plant count a plop stands for: its explicit count, else // the one derived from its area and the plant's spacing. func effectiveCount(pl domain.Planting) int { if pl.Count != nil { return *pl.Count } return pl.DerivedCount } // dateRange is the planting date shared by a group's plops, "first…last" when // they were planted on different days, or "" when none is dated. ISO dates // order as strings, so min/max need no parsing. func dateRange(plops []domain.Planting) string { first, last := "", "" for _, pl := range plops { if pl.PlantedAt == nil || *pl.PlantedAt == "" { continue } if first == "" || *pl.PlantedAt < first { first = *pl.PlantedAt } if *pl.PlantedAt > last { last = *pl.PlantedAt } } if first == last { return first } return first + "…" + last } // summarizeWhere names where a group of plops sits in its object, in the words // NamedRegion understands when that is exact ("north half", "NE corner"), and // otherwise as honestly as it can: "throughout" for a group spanning most of the // object, a short list of rough locations, or the bounding box of the plop // centres in local cm — which is what a fill needs to put something back there. func summarizeWhere(o *domain.GardenObject, plops []domain.Planting) string { if len(plops) == 1 { return describeLocation(plops[0].XCM, plops[0].YCM) } minX, maxX := plops[0].XCM, plops[0].XCM minY, maxY := plops[0].YCM, plops[0].YCM for _, pl := range plops[1:] { minX, maxX = math.Min(minX, pl.XCM), math.Max(maxX, pl.XCM) minY, maxY = math.Min(minY, pl.YCM), math.Max(maxY, pl.YCM) } const eps = 1e-6 // A half is "everything on one side of the centre line, and not just ON it": // a column of plops down the middle is neither the west half nor the east. north := maxY <= eps && minY < -eps south := minY >= -eps && maxY > eps west := maxX <= eps && minX < -eps east := minX >= -eps && maxX > eps switch { case north && west: return "NW corner" case north && east: return "NE corner" case south && west: return "SW corner" case south && east: return "SE corner" case north: return "north half" case south: return "south half" case west: return "west half" case east: return "east half" } // Centres spanning at least 60% of both dimensions is a whole-object fill // (the outer row sits half a spacing in from each edge). if hw, hh := o.WidthCM/2, o.HeightCM/2; hw > 0 && hh > 0 && maxX-minX >= 1.2*hw && maxY-minY >= 1.2*hh { return "throughout" } var locs []string seen := map[string]bool{} for _, pl := range plops { if l := describeLocation(pl.XCM, pl.YCM); !seen[l] { seen[l] = true locs = append(locs, l) } } if len(locs) <= 3 { return strings.Join(locs, ", ") } return fmt.Sprintf("x %.0f…%.0f, y %.0f…%.0f cm from the centre", minX, maxX, minY, maxY) } // 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" } }