Address fill-mode review: grid edge inset + drop dead coverage append
Build image / build-and-push (push) Successful in 18s
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
This commit is contained in:
@@ -7,8 +7,8 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
|
|||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
|
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
|
||||||
- **A fill is one of two operations (#77).** A plop is a *clump*, not a plant, which is the right primitive for SKETCHING ("a few plops of garlic in a corner") but can't draw a real planting — a filled bed comes out as ~15 blobs, not 8 rows of garlic. So `FillRegion`/`FillNamedRegion` take a `FillLayout`: `clump` (default; plop radius 1.5×spacing, ~7 plants each — quick coverage) or `grid` (radius spacing/2, pitch = spacing, ONE plant per plop — a layout you could plant from). Same `hexCenters` lattice and #75 edge rule for both; only the radius→spacing relationship differs. Surfaced on `POST /objects/:id/fill` (`layout`) and the agent's `fill_region` (`mode`). A grid-filled bed approaches the low-hundreds-of-plops the SVG budget was sized for, which the semantic-zoom tiers already anticipate.
|
- **A fill is one of two operations (#77).** A plop is a *clump*, not a plant, which is the right primitive for SKETCHING ("a few plops of garlic in a corner") but can't draw a real planting — a filled bed comes out as ~15 blobs, not 8 rows of garlic. So `FillRegion`/`FillNamedRegion` take a `FillLayout`: `clump` (default; plop radius 1.5×spacing, ~7 plants each — quick coverage) or `grid` (radius spacing/2, pitch = spacing, ONE plant per plop — a layout you could plant from). Same `hexCenters` lattice and #75 edge rule for both; only the radius→spacing relationship differs. Surfaced on `POST /objects/:id/fill` (`layout`) and the agent's `fill_region` (`mode`). Same centered `hexCenters` lattice for both, but BOTH the plop radius (`plopRadiusFor`) and the edge inset (`edgeInset`) differ by layout: a grid plant sits at the plop's centre, so it insets a half-spacing; a clump's plants reach its rim, so it insets radius-less-a-half and overhangs the edge by that half — reusing the clump formula for grid would inset by zero and plant flush on the edge. A grid-filled bed approaches the low-hundreds-of-plops the SVG budget was sized for, which the semantic-zoom tiers already anticipate.
|
||||||
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and the failure mode it prevents are written out once in `hexCenters`; #75 is what getting it wrong looked like.
|
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and how it differs by layout are written out once in `edgeInset` (which `hexCenters` then honours); #75 is what getting it wrong looked like.
|
||||||
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
|
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
|
||||||
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
|
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
|
||||||
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
||||||
|
|||||||
@@ -95,22 +95,39 @@ func TestFillAndClearAPI(t *testing.T) {
|
|||||||
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
|
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
|
||||||
t.Errorf("second clear removed %d, want 0", n)
|
t.Errorf("second clear removed %d, want 0", n)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Grid layout (#77) packs individual plants at true spacing — many more, small
|
// TestFillLayoutAPI covers the layout selector (#77): grid packs denser than the
|
||||||
// plops than the clump default on the same bed.
|
// clump default, and an unknown layout is a 400 rather than a silent clump fill.
|
||||||
|
func TestFillLayoutAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
||||||
|
|
||||||
|
// Clump (default) for the baseline count.
|
||||||
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "all",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clump fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
clumpN := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clear between fills: %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grid packs individual plants at true spacing — many more, small plops.
|
||||||
w = doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
w = doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
"plantId": plantID, "region": "all", "layout": "grid",
|
"plantId": plantID, "region": "all", "layout": "grid",
|
||||||
}, cookie)
|
}, cookie)
|
||||||
if w.Code != http.StatusOK {
|
if w.Code != http.StatusOK {
|
||||||
t.Fatalf("grid fill: status %d, body %s", w.Code, w.Body.String())
|
t.Fatalf("grid fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
if gridN := int(decodeMap(t, w.Body.Bytes())["created"].(float64)); gridN <= created {
|
if gridN := int(decodeMap(t, w.Body.Bytes())["created"].(float64)); gridN <= clumpN {
|
||||||
t.Errorf("grid fill created %d, want more than the clump fill's %d", gridN, created)
|
t.Errorf("grid fill created %d, want more than the clump fill's %d", gridN, clumpN)
|
||||||
}
|
}
|
||||||
|
|
||||||
// An unknown layout is a 400, not a silent clump fill.
|
// An unknown layout is a 400, not a silent clump fill.
|
||||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
|
||||||
t.Fatalf("clear before bad-layout: %d", w.Code)
|
|
||||||
}
|
|
||||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
"plantId": plantID, "region": "all", "layout": "spiral",
|
"plantId": plantID, "region": "all", "layout": "spiral",
|
||||||
}, cookie); w.Code != http.StatusBadRequest {
|
}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
|||||||
+56
-38
@@ -127,6 +127,33 @@ func plopRadiusFor(spacingCM float64, layout FillLayout) float64 {
|
|||||||
return defaultPlopRadius(spacingCM)
|
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
|
// validFillLayout normalizes a layout: empty defaults to clump (so existing
|
||||||
// callers are unchanged), a known value passes, anything else is rejected.
|
// callers are unchanged), a known value passes, anything else is rejected.
|
||||||
func validFillLayout(l FillLayout) (FillLayout, bool) {
|
func validFillLayout(l FillLayout) (FillLayout, bool) {
|
||||||
@@ -140,14 +167,15 @@ func validFillLayout(l FillLayout) (FillLayout, bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
// FillRegion lays a field of plops of one plant across a region of a plantable
|
||||||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
// object the actor can edit. The layout picks the primitive: FillClump drops fat
|
||||||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
// clumps for quick sketching, FillGrid lays out individual plants at true spacing
|
||||||
// radius pitch, centered in the region, and set in from each edge by the plop's
|
// (see FillLayout). Plop radius comes from the plant's spacing (or spacingOverride)
|
||||||
// radius less half a spacing — see hexCenters for why that half-spacing is what
|
// via plopRadiusFor; centers sit on a centered hex lattice at 2×radius pitch, set
|
||||||
// the edge is owed. A candidate is skipped when its plop would sit entirely
|
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
|
||||||
// inside an existing active plop (so re-filling doesn't stack duplicates).
|
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its
|
||||||
// Returns the plops it created.
|
// 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) {
|
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)
|
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -157,9 +185,10 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||||||
// already loaded and authorized (roleEditor). It rejects a non-finite region,
|
// already loaded and authorized (roleEditor). It validates the layout, rejects a
|
||||||
// clamps the region to the object's bounds, refuses fills over maxFillPlops, and
|
// non-finite region, clamps the region to the object's bounds, refuses fills over
|
||||||
// inserts the whole batch in one transaction rather than one round-trip per plop.
|
// 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) {
|
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 {
|
if !o.Plantable {
|
||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
@@ -197,7 +226,7 @@ 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, total := hexCenters(region, radius, spacing, maxFillPlops)
|
centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops)
|
||||||
if total > 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
|
||||||
}
|
}
|
||||||
@@ -208,13 +237,17 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
|||||||
}
|
}
|
||||||
today := s.now().UTC().Format(dateLayout)
|
today := s.now().UTC().Format(dateLayout)
|
||||||
batch := make([]*domain.Planting, 0, len(centers))
|
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 {
|
for _, c := range centers {
|
||||||
if coveredByExisting(c.x, c.y, radius, existing) {
|
if coveredByExisting(c.x, c.y, radius, existing) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
|
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today})
|
||||||
batch = append(batch, p)
|
|
||||||
existing = append(existing, *p) // so later candidates in THIS fill don't stack on it
|
|
||||||
}
|
}
|
||||||
created, err := s.store.CreatePlantings(ctx, batch)
|
created, err := s.store.CreatePlantings(ctx, batch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -243,24 +276,14 @@ type localPoint struct{ x, y float64 }
|
|||||||
//
|
//
|
||||||
// # How close to the edge the outer row goes
|
// # How close to the edge the outer row goes
|
||||||
//
|
//
|
||||||
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
|
// The caller passes `inset`: the margin the outer row keeps from every edge. It
|
||||||
// soil, light and water. A bed edge is not a competitor, so the outer row only
|
// encodes the half-spacing rule (spacing is owed between neighbouring plants, and
|
||||||
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
|
// a bed edge is nobody's neighbour) and differs by layout — see edgeInset, which
|
||||||
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
|
// derives it. hexCenters just honours it on all four sides.
|
||||||
// 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
|
// Do not "simplify" the centering back to anchoring at the region's min corner.
|
||||||
// three spacings across — and its plants sit out to its rim. So keeping the whole
|
// That is what #75 was: staggered rows start a full pitch in, and the leftover all
|
||||||
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
|
// lands on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||||||
// 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.
|
|
||||||
//
|
|
||||||
// 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
|
|
||||||
// on the far edge, where clumps hang outside a bed that nothing clips them to.
|
|
||||||
//
|
//
|
||||||
// # Counting before building
|
// # Counting before building
|
||||||
//
|
//
|
||||||
@@ -268,7 +291,7 @@ type localPoint struct{ x, y float64 }
|
|||||||
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
// 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
|
// 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.
|
// returns (nil, total), so the caller can still refuse with the real number.
|
||||||
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
|
func hexCenters(r Region, radius, inset float64, limit int) ([]localPoint, int) {
|
||||||
if radius <= 0 {
|
if radius <= 0 {
|
||||||
return nil, 0
|
return nil, 0
|
||||||
}
|
}
|
||||||
@@ -281,11 +304,6 @@ func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int
|
|||||||
pitch := 2 * radius
|
pitch := 2 * radius
|
||||||
rowH := pitch * math.Sqrt(3) / 2
|
rowH := pitch * math.Sqrt(3) / 2
|
||||||
|
|
||||||
// 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)
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ 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, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops)
|
pts, total := hexCenters(r, tc.radius, edgeInset(tc.radius, tc.spacing, FillClump), maxFillPlops)
|
||||||
if len(pts) == 0 {
|
if len(pts) == 0 {
|
||||||
t.Fatal("no centers")
|
t.Fatal("no centers")
|
||||||
}
|
}
|
||||||
@@ -173,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, maxFillPlops)
|
pts, _ := hexCenters(tc.r, 15, edgeInset(15, 10, FillClump), 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)
|
||||||
}
|
}
|
||||||
@@ -323,6 +323,7 @@ func TestFillGridLaysOutIndividualPlants(t *testing.T) {
|
|||||||
t.Errorf("grid produced %d plops, clump %d — grid should be denser", len(grid), len(clump))
|
t.Errorf("grid produced %d plops, clump %d — grid should be denser", len(grid), len(clump))
|
||||||
}
|
}
|
||||||
// Each grid plop is one plant at radius spacing/2 = 5.
|
// Each grid plop is one plant at radius spacing/2 = 5.
|
||||||
|
maxAbs := 0.0
|
||||||
for _, p := range grid {
|
for _, p := range grid {
|
||||||
if p.RadiusCM != 5 {
|
if p.RadiusCM != 5 {
|
||||||
t.Errorf("grid plop radius = %v, want 5 (spacing/2)", p.RadiusCM)
|
t.Errorf("grid plop radius = %v, want 5 (spacing/2)", p.RadiusCM)
|
||||||
@@ -330,6 +331,14 @@ func TestFillGridLaysOutIndividualPlants(t *testing.T) {
|
|||||||
if p.DerivedCount != 1 {
|
if p.DerivedCount != 1 {
|
||||||
t.Errorf("grid plop derived count = %d, want 1 (one plant per plop)", p.DerivedCount)
|
t.Errorf("grid plop derived count = %d, want 1 (one plant per plop)", p.DerivedCount)
|
||||||
}
|
}
|
||||||
|
maxAbs = math.Max(maxAbs, math.Max(math.Abs(p.XCM), math.Abs(p.YCM)))
|
||||||
|
}
|
||||||
|
// The half-spacing edge rule: a grid plant sits AT the plop centre, so the
|
||||||
|
// outer row is inset a half-spacing (spacing/2 = 5) — at ±25 on this 60cm bed,
|
||||||
|
// not flush on ±30. Regression guard: the clump inset formula (radius − half)
|
||||||
|
// collapses to 0 for grid and would plant one on the very edge.
|
||||||
|
if edge := 30.0; maxAbs > edge-5+1e-6 {
|
||||||
|
t.Errorf("outermost grid plop at |coord|=%.2f — only %.2fcm from the edge; want a half-spacing (5cm) in", maxAbs, edge-maxAbs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user