Fill: honour the half-spacing edge rule when packing plops #76

Merged
steve merged 7 commits from fix/fill-edge-spacing into main 2026-07-21 18:28:18 +00:00
3 changed files with 62 additions and 19 deletions
Showing only changes of commit 28af101634 - Show all commits
+15
View File
@@ -129,6 +129,21 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
A push to `main` builds the image and deploys to Komodo; the live instance at A push to `main` builds the image and deploys to Komodo; the live instance at
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later. `pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
**Gadfly reviews the PR as opened, not as merged.** The workflow triggers on
`opened`/`reopened`/`ready_for_review` — deliberately *not* `synchronize` — so
every commit you push afterwards, including the ones you push in response to
Gadfly itself, is unreviewed unless you ask. Once you've stopped pushing and
before you merge, comment **`@gadfly review`** on the PR to re-trigger it. The
phrase is required, and this is not hypothetical: on #76 the follow-up commit
was the one that contained a real bug.
**A skipped Gadfly run reports success.** A comment without the trigger phrase
still starts the workflow, which logs `comment does not contain trigger phrase`
and exits green in ~2 seconds. So "the pipeline is green" does NOT mean "this
was reviewed". Confirm a re-review actually ran by checking that it posted a new
consensus comment — or that the run took minutes rather than seconds — not by
its status.
Workflow- and config-only changes (CI, this file, docs) go straight to `main` Workflow- and config-only changes (CI, this file, docs) go straight to `main`
without the PR dance. without the PR dance.
+40 -17
View File
@@ -29,10 +29,8 @@ type Region struct {
} }
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH), // 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. // 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().
// Note this INVERTS (Max ends up below Min) rather than empties a region that
// misses the object altogether — hexCenters treats that as "nothing to plant".
func (r Region) clampTo(halfW, halfH float64) Region { func (r Region) clampTo(halfW, halfH float64) Region {
return Region{ return Region{
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH), MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
@@ -40,6 +38,16 @@ func (r Region) clampTo(halfW, halfH float64) Region {
} }
} }
// 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. // rect builds a rectangular region.
func rect(minX, minY, maxX, maxY float64) Region { func rect(minX, minY, maxX, maxY float64) Region {
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY} return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
@@ -142,8 +150,8 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
} }
region = region.clampTo(o.WidthCM/2, o.HeightCM/2) region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
centers := hexCenters(region, radius, spacing) centers, total := hexCenters(region, radius, spacing, maxFillPlops)
if len(centers) > maxFillPlops { if total > maxFillPlops {
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
} }
1
@@ -206,16 +214,19 @@ type localPoint struct{ x, y float64 }
// Do not "simplify" this back to anchoring at the region's min corner. That is // Do not "simplify" this 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 // 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. // on the far edge, where clumps hang outside a bed that nothing clips them to.
func hexCenters(r Region, radius, spacing float64) []localPoint { // It reports 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 refuse with the real number.
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
if radius <= 0 { if radius <= 0 {
return nil return nil, 0
} }
// clampTo INVERTS a region that lies wholly outside the object (Max clamps // An empty region has no inside to plant. The old loop-until-past-MaxX form
// below Min), and an inverted region has no inside to plant. The old // got this for free by never entering the loop; counting positions up front
// loop-until-past-MaxX form got this for free by never entering the loop; // does not, and would site a plop off the bed.
// counting positions up front does not, and would site a plop off the bed. if r.empty() {
if r.MaxX < r.MinX || r.MaxY < r.MinY { return nil, 0
return nil
} }
pitch := 2 * radius pitch := 2 * radius
rowH := pitch * math.Sqrt(3) / 2 rowH := pitch * math.Sqrt(3) / 2
@@ -228,7 +239,19 @@ func hexCenters(r Region, radius, spacing float64) []localPoint {
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset) rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset) cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
pts := make([]localPoint, 0, rows*cols) // 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++ { for row := 0; row < rows; row++ {
y := r.MinY + y0 + float64(row)*rowH y := r.MinY + y0 + float64(row)*rowH
n, x := cols, r.MinX+x0 n, x := cols, r.MinX+x0
@@ -236,13 +259,13 @@ func hexCenters(r Region, radius, spacing float64) []localPoint {
// and centering THAT run puts it exactly half a pitch off its neighbours. // and centering THAT run puts it exactly half a pitch off its neighbours.
// A single-column region has nothing to stagger against. // A single-column region has nothing to stagger against.
if row%2 == 1 && cols > 1 { if row%2 == 1 && cols > 1 {
n, x = cols-1, r.MinX+x0+radius n, x = staggered, r.MinX+x0+radius
} }
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
pts = append(pts, localPoint{x + float64(i)*pitch, y}) pts = append(pts, localPoint{x + float64(i)*pitch, y})
} }
} }
return pts return pts, total
} }
// fitAxis returns how many lattice positions fit along a span at `step`, keeping // fitAxis returns how many lattice positions fit along a span at `step`, keeping
+7 -2
View File
@@ -96,10 +96,15 @@ func TestHexCentersEdgeInset(t *testing.T) {
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2) r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
pts := hexCenters(r, tc.radius, tc.spacing) pts, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops)
if len(pts) == 0 { if len(pts) == 0 {
t.Fatal("no centers") t.Fatal("no centers")
} }
// The count is derived up front so an oversized fill is refused without
// building its lattice — which only works if it matches what gets built.
if total != len(pts) {
t.Errorf("reported total %d, built %d", total, len(pts))
}
// A clump may cross the edge, but only by the half-spacing the rule // A clump may cross the edge, but only by the half-spacing the rule
// allows — never enough to be mostly out in the path. // allows — never enough to be mostly out in the path.
@@ -168,7 +173,7 @@ func TestHexCentersTinyRegion(t *testing.T) {
{"off in a corner", rect(20, -40, 30, -30), 25, -35}, {"off in a corner", rect(20, -40, 30, -30), 25, -35},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
pts := hexCenters(tc.r, 15, 10) pts, _ := hexCenters(tc.r, 15, 10, maxFillPlops)
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY { if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY) t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
} }