Fill: honour the half-spacing edge rule when packing plops (#75)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 10m7s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s

Filling a bed left the outer row too far from the edge, and staggered rows
worse still. Two defects, both from anchoring the lattice at the region's min
corner:

- Odd rows offset by `radius` started at `MinX + 2·radius`, leaving a bare
  strip a whole plop wide down one side of every other row.
- All the leftover slack piled up on the far edge, where plops hung 13cm
  outside the bed on a 4×8ft garlic bed. Nothing clips them, so they drew
  over the bed outline.

Spacing is a constraint between neighbouring plants competing for the same
soil, light and water. A bed edge is not a competitor, so the outer row owes
it half the spacing — the arithmetic inside every square-foot-gardening chart
(4/square = 6" apart, 3" from the square's edge).

The wrinkle: a plop is a CLUMP, not a plant. defaultPlopRadius is 1.5×spacing,
so keeping the whole circle inside the bed insets the outer row by 1.5
spacings, three times what the rule allows. So centre the lattice and set the
minimum centre-inset to `radius - spacing/2`: the clump may cross the edge by
up to half a spacing, putting its outermost plants exactly the half-spacing
from the edge the rule asks for. Capped there — a clump mostly outside the bed
would be a drawing of plants in the path.

Same bed, same 15 plops, now symmetric with a deliberate 6.5cm overhang inside
the 7.5cm budget instead of an accidental 13cm on one side only. The stagger
falls out of the centring for free: an offset row holds one fewer plop, and
centring that run puts it exactly half a pitch off its neighbours.

TestFillRegionDeterministicPacking expected 4 plops in a 60×60 bed; the fourth
was centred ON the east edge with half of it outside, well past the budget.
It is 3 now — the fix working, not a regression in it.

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-21 10:11:08 -04:00
co-authored by Claude Opus 4.8
parent c1c873d3f0
commit 45da4b15e2
4 changed files with 182 additions and 31 deletions
+72 -26
View File
@@ -28,11 +28,6 @@ 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 {
@@ -94,9 +89,10 @@ func defaultPlopRadius(spacingCM float64) float64 {
// 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.
// radius pitch, centered in the region with a half-pitch inset at each edge (see
// hexCenters for why that inset and not a full one). 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 {
@@ -130,7 +126,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
}
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
centers := hexCenters(region, radius)
centers := hexCenters(region, radius, spacing)
if len(centers) > maxFillPlops {
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
}
@@ -169,34 +165,84 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
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 {
// 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
//
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
// soil, light and water. A bed edge is not a competitor, so the outer row only
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
//
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so
// three spacings across — and its plants sit out to its rim. So keeping the whole
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
// times what the rule allows. Instead the clump may hang over the edge by up to
// half a spacing, which puts its outermost plants exactly the half-spacing from
// the edge that the rule asks for. Overhang is capped there and nowhere near the
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
//
// Anchoring at the min corner instead (what this did originally) was wrong twice
// over: staggered rows started a FULL pitch in, leaving a clump-sized bare strip
// down one side of every other row, while the far edge had clumps hanging off it
// by 13cm — and nothing clips them, so they drew outside the bed.
func hexCenters(r Region, radius, spacing 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
// How far a clump's centre must stay inside the edge: its own radius, less the
// half-spacing of overhang the rule allows. Never negative, and never past the
// centre of the clump.
inset := math.Max(0, radius-math.Max(0, spacing)/2)
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
pts := make([]localPoint, 0, rows*cols)
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 = cols-1, r.MinX+x0+radius
}
for x := xStart; x <= r.MaxX+eps; x += pitch {
if r.contains(x, y) {
pts = append(pts, localPoint{x, y})
}
for i := 0; i < n; i++ {
pts = append(pts, localPoint{x + float64(i)*pitch, y})
}
row++
}
return pts
}
// 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.
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 {