Build image / build-and-push (push) Successful in 18s
Gadfly findings on #95: - Correctness (3 models): the shared inset formula radius-spacing/2 collapses to 0 in grid mode (radius = spacing/2), so grid's outer row planted flush on / overhanging the bed edge instead of the half-spacing in that the rule wants. The inset genuinely differs by layout — a grid plant sits AT the plop centre (inset spacing/2), a clump's plants reach its rim (inset radius-spacing/2, overhanging by a half). Split it into a new edgeInset(radius, spacing, layout); hexCenters now takes a precomputed inset and is pure geometry (no spacing/layout knowledge). Regression guard: grid plants land at ±25 on a 60cm bed, not ±30. - Performance (2 findings): the in-loop `existing = append(existing, *p)` was dead — every plop in one fill shares a radius and sits on a distinct lattice point, and a plop is "covered" only when wholly inside another, impossible between equal-radius circles at different centres. Removing it stops the coveredByExisting scan growing during the fill (an empty-bed grid fill's check was needlessly quadratic in the plop count). - Docs: FillRegion/fillLoaded/hexCenters comments and the DESIGN.md bullets updated for plopRadiusFor/edgeInset (were still citing defaultPlopRadius and "written out in hexCenters"). - Test hygiene: split the grid + bad-layout cases out of TestFillAndClearAPI into TestFillLayoutAPI (one concern per test). The enum-tag finding is a non-issue: majordomo's DefineTool derives its arg schema from the same struct-tag reflection as Generate (proven by the vision SeedPacket enum), and the service validates mode regardless. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
566 lines
22 KiB
Go
566 lines
22 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)
|
||
}
|
||
|
||
// 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). Returns the plops it created.
|
||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]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, layout)
|
||
}
|
||
|
||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||
// already loaded and authorized (roleEditor). 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, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
|
||
if !o.Plantable {
|
||
return nil, domain.ErrInvalidInput
|
||
}
|
||
layout, ok := validFillLayout(layout)
|
||
if !ok {
|
||
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 := 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
|
||
}
|
||
today := s.now().UTC().Format(dateLayout)
|
||
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: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today})
|
||
}
|
||
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) ([]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, layout)
|
||
}
|
||
|
||
// 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"
|
||
}
|
||
}
|