Fill mode: a "grid" layout that plants individual plants in rows (#77)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 12m51s
Adversarial Review (Gadfly) / review (pull_request) Successful in 12m51s

Steve chose option 3: keep clumps as the default primitive, add a grid/rows
fill mode, so sketching and planning are different operations with different
outputs rather than one model forced to be both.

A plop is a CLUMP, not a plant — great for "a few plops of garlic in a corner",
useless for drawing a plantable 8-rows-of-garlic bed (that came out as ~15
blobs, #77). FillLayout selects what a fill packs:
- clump (default, unchanged): radius 1.5×spacing, ~7 plants per plop.
- grid: radius spacing/2, pitch = spacing, ONE plant per plop — rows you could
  actually plant from.

The geometry is the SAME hexCenters lattice and the SAME #75 half-spacing edge
rule; only the radius→spacing relationship differs (plopRadiusFor). Grid keeps
no 15cm floor — its whole point is true spacing — while clump keeps it so a
tiny-spacing plant doesn't make invisible clumps.

Threaded through FillRegion/FillNamedRegion (empty layout = clump, so existing
callers are unchanged; unknown layout = ErrInvalidInput), the REST /fill
endpoint (`layout`), and the agent's fill_region tool (`mode`, enum clump|grid),
so "plant the bed in rows" works.

Tests: grid produces many more, single-plant plops than clump on the same bed
(radius spacing/2, derived count 1); unknown layout is refused at both the
service and the API. maxFillPlops still caps a grid fill of a huge bed.

No frontend fill affordance exists yet (fill is agent-only in the UI; the fill
UI was deferred in #82), so the mode toggle rides along when that's built —
noted. Docs: DESIGN placement-model decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-22 00:01:27 -04:00
co-authored by Claude Opus 4.8
parent 1e2b763566
commit 7c1faa1515
9 changed files with 154 additions and 25 deletions
+53 -6
View File
@@ -97,6 +97,49 @@ 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)
}
// 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 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×
@@ -105,22 +148,26 @@ func defaultPlopRadius(spacingCM float64) float64 {
// 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) {
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)
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 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) ([]domain.Planting, error) {
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
@@ -132,7 +179,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
}
spacing = *spacingOverride
}
radius := defaultPlopRadius(spacing)
radius := plopRadiusFor(spacing, layout)
if !isFinite(radius) || radius <= 0 {
return nil, domain.ErrInvalidInput
}
@@ -311,7 +358,7 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
// 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) {
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
@@ -320,7 +367,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
if err != nil {
return nil, err
}
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
}
// ClearObject soft-removes every active plop in an object the actor can edit (one