Shapes dropped at seeded jittered positions with rectangular overlaps (>= one whole line, <= 25% of the smaller tile), holes left as void, a hole-fill pass with a relaxed filler rule, ValidShape incl. the distinct-lines bound; grid, big-first and bands kept as square layouts.
- commit
- 9b64e24301e84116b771f74ef5844a1a7e1fdf3d
- parent
- acb987513cacad71971a4945b8c0ade7f844d25a
- author
- dami <gazey55@gmail.com>
| A | engine/ | 1272 | ++++++++++++++++++++++++++++++ |
| A | engine/ | 355 | ++++++++ |
@@ -0,0 +1,1272 @@
1+// Package pack lays out a field as tiles of arbitrary shape (manifest v3).2+// A tile is a union of axis-aligned rectangles whose every maximal3+// horizontal and vertical run of cells (a line) has even length >= 4. Tiles4+// may overlap by any set of cells; uncovered cells are holes. Deterministic5+// per seed.6+//7+// Kinds: "scatter" drops shapes at random positions, sizes and orientations,8+// growing one cluster from the seed tile; "grid" is X-by-X squares of side9+// N on a pitch of N-1 (the phase-1 standard and the envelope test);10+// "big-first" and "bands" are the phase-2 square packers (kept; they11+// produce square tiles that overlap by one cell).12+package pack13+14+import (15+ "crypto/sha256"16+ "encoding/binary"17+ "errors"18+ "fmt"19+ "math/rand/v2"20+ "sort"21+)22+23+const (24+ MinN = 6 // smallest square side the square packers draw25+ MaxN = 16 // largest side of anything26+27+ MinLine = 4 // shortest line a shape may have (even)28+)29+30+// MFor is the decided cap on solutions-alone for a tile of the given cell31+// count: 2 up to 64 cells, 3 up to 196, 4 beyond.32+func MFor(cells int) int {33+ switch {34+ case cells <= 64:35+ return 236+ case cells <= 196:37+ return 338+ default:39+ return 440+ }41+}42+43+// MByN is MFor for an n-by-n square (kept for the square kinds).44+func MByN(n int) int { return MFor(n * n) }45+46+type Params struct {47+ Seed string `json:"seed"`48+ G int `json:"G"` // cells per side of the field49+ Kind string `json:"kind"` // scatter | grid | big-first | bands50+ // grid: every tile the same size51+ N int `json:"n,omitempty"`52+ // big-first: fraction of pitch-space area to cover with large squares53+ // before filling, and the large sizes to draw from (n values).54+ BigArea float64 `json:"big_area,omitempty"`55+ BigSizes []int `json:"big_sizes,omitempty"`56+ // scatter: target share of the field covered (default 0.72), cell count57+ // range of a tile (default 16..256), share of drops that are unions of58+ // two rectangles (default 0.4).59+ Coverage float64 `json:"coverage,omitempty"`60+ MinCells int `json:"min_cells,omitempty"`61+ MaxCells int `json:"max_cells,omitempty"`62+ Compound float64 `json:"compound,omitempty"`63+ // scatter: after the drop loop, holes larger than this many cells are64+ // filled with small tiles under the same rule (default 24); smaller65+ // holes are the gaps and stay66+ MaxHole int `json:"max_hole,omitempty"`67+}68+69+// Tile is a shape placed on the field: a bounding box and the rectangles70+// (local coordinates) whose union is the shape.71+type Tile struct {72+ ID int `json:"id"`73+ X0 int `json:"x0"`74+ Y0 int `json:"y0"`75+ W int `json:"w"`76+ H int `json:"h"`77+ Rects [][4]int `json:"rects"` // local [lx, ly, rw, rh]; rects may overlap78+ Cells int `json:"cells"`79+ M int `json:"m"`80+}81+82+// Overlap is the set of cells a tile shares with one other tile.83+type Overlap struct {84+ With int `json:"with"`85+ Cells int `json:"cells"`86+ Ids []int `json:"-"` // shared global cell ids (y*G+x), sorted87+}88+89+type Layout struct {90+ G int `json:"G"`91+ Kind string `json:"kind"`92+ SeedTile int `json:"seed_tile"`93+ Tiles []Tile `json:"tiles"`94+ Overlaps [][]Overlap `json:"overlaps"` // per tile id, sorted by With95+ Report Report `json:"report"`96+}97+98+type Report struct {99+ Tiles int `json:"tiles"`100+ Coverage float64 `json:"coverage"` // covered cells / G*G101+ Holes int `json:"holes"` // 4-connected uncovered components inside the cluster's bounding box102+ HoleCells int `json:"hole_cells"`103+ MeanHole float64 `json:"mean_hole"`104+ ByCells map[string]int `json:"by_cells"` // tiles per cell-count bucket105+ Overlaps int `json:"overlaps"` // tile pairs sharing a cell106+ MeanOverlap float64 `json:"mean_overlap"`107+ Compound int `json:"compound"` // non-rectangular tiles108+ MaxHole int `json:"max_hole,omitempty"`109+ Filled int `json:"filled,omitempty"` // tiles added by the hole-fill pass110+ Stopped string `json:"stopped,omitempty"`111+}112+113+// Rect is a rectangle in field or pitch coordinates.114+type Rect struct {115+ X0, Y0, W, H int116+}117+118+func rngFor(parts ...string) *rand.Rand {119+ h := sha256.New()120+ for _, s := range parts {121+ h.Write([]byte(s))122+ h.Write([]byte{0})123+ }124+ sum := h.Sum(nil)125+ return rand.New(rand.NewPCG(binary.LittleEndian.Uint64(sum[:8]), binary.LittleEndian.Uint64(sum[8:16])))126+}127+128+// ---------------------------------------------------------------- shapes129+130+// Mask returns the tile's cells as a local W*H boolean grid.131+func (t Tile) Mask() []bool {132+ m := make([]bool, t.W*t.H)133+ for _, r := range t.Rects {134+ for y := r[1]; y < r[1]+r[3]; y++ {135+ for x := r[0]; x < r[0]+r[2]; x++ {136+ if x >= 0 && y >= 0 && x < t.W && y < t.H {137+ m[y*t.W+x] = true138+ }139+ }140+ }141+ }142+ return m143+}144+145+// Lines lists every maximal horizontal run, then every maximal vertical146+// run, of the shape's cells as local indexes ly*W+lx in order along the run.147+func (t Tile) Lines() [][]int {148+ return maskLines(t.Mask(), t.W, t.H)149+}150+151+func maskLines(mask []bool, W, H int) [][]int {152+ var out [][]int153+ for y := 0; y < H; y++ {154+ x := 0155+ for x < W {156+ if !mask[y*W+x] {157+ x++158+ continue159+ }160+ var run []int161+ for x < W && mask[y*W+x] {162+ run = append(run, y*W+x)163+ x++164+ }165+ out = append(out, run)166+ }167+ }168+ for x := 0; x < W; x++ {169+ y := 0170+ for y < H {171+ if !mask[y*W+x] {172+ y++173+ continue174+ }175+ var run []int176+ for y < H && mask[y*W+x] {177+ run = append(run, y*W+x)178+ y++179+ }180+ out = append(out, run)181+ }182+ }183+ return out184+}185+186+// ValidShape checks the shape rules: every line has even length >= MinLine,187+// the cells are 4-connected, and the bounding box has no unused row or188+// column.189+func ValidShape(mask []bool, W, H int) error {190+ if len(mask) != W*H || W <= 0 || H <= 0 {191+ return errors.New("mask does not match its bounding box")192+ }193+ cells := 0194+ for _, m := range mask {195+ if m {196+ cells++197+ }198+ }199+ if cells == 0 {200+ return errors.New("empty shape")201+ }202+ lines := maskLines(mask, W, H)203+ for _, run := range lines {204+ if len(run)%2 != 0 || len(run) < MinLine {205+ return fmt.Errorf("line of length %d (need even, >= %d)", len(run), MinLine)206+ }207+ }208+ // uniqueness has to be satisfiable with room to spare: per orientation209+ // and length L, at most half of the distinct valid lines of length L210+ // may be needed (rows come first in maskLines, then columns)211+ rows := 0212+ for y := 0; y < H; y++ {213+ x := 0214+ for x < W {215+ if mask[y*W+x] {216+ rows++217+ for x < W && mask[y*W+x] {218+ x++219+ }220+ } else {221+ x++222+ }223+ }224+ }225+ for _, part := range [][][]int{lines[:rows], lines[rows:]} {226+ byLen := map[int]int{}227+ for _, run := range part {228+ byLen[len(run)]++229+ }230+ for L, n := range byLen {231+ if n > DistinctLines(L)/2 {232+ return fmt.Errorf("%d parallel lines of length %d, at most %d allowed (half of the %d distinct valid lines)", n, L, DistinctLines(L)/2, DistinctLines(L))233+ }234+ }235+ }236+ for y := 0; y < H; y++ {237+ used := false238+ for x := 0; x < W; x++ {239+ used = used || mask[y*W+x]240+ }241+ if !used {242+ return fmt.Errorf("row %d of the bounding box is unused", y)243+ }244+ }245+ for x := 0; x < W; x++ {246+ used := false247+ for y := 0; y < H; y++ {248+ used = used || mask[y*W+x]249+ }250+ if !used {251+ return fmt.Errorf("column %d of the bounding box is unused", x)252+ }253+ }254+ // 4-connectivity255+ start := -1256+ for i, m := range mask {257+ if m {258+ start = i259+ break260+ }261+ }262+ seen := make([]bool, W*H)263+ stack := []int{start}264+ seen[start] = true265+ reached := 0266+ for len(stack) > 0 {267+ c := stack[len(stack)-1]268+ stack = stack[:len(stack)-1]269+ reached++270+ x, y := c%W, c/W271+ for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {272+ nx, ny := x+d[0], y+d[1]273+ if nx < 0 || ny < 0 || nx >= W || ny >= H {274+ continue275+ }276+ id := ny*W + nx277+ if mask[id] && !seen[id] {278+ seen[id] = true279+ stack = append(stack, id)280+ }281+ }282+ }283+ if reached != cells {284+ return errors.New("shape is not 4-connected")285+ }286+ return nil287+}288+289+var distinctCache = map[int]int{}290+291+// DistinctLines counts the binary lines of even length L that are balanced292+// and have no three equal cells in a row: 6 at L=4, 14 at 6, 34 at 8, 84 at293+// 10, 208 at 12, 518 at 14, 1296 at 16. Enumerated, and memoised.294+func DistinctLines(L int) int {295+ if n, ok := distinctCache[L]; ok {296+ return n297+ }298+ count := 0299+ var rec func(pos, ones int, a, b int8)300+ rec = func(pos, ones int, a, b int8) {301+ if ones > L/2 || pos-ones > L/2 {302+ return303+ }304+ if pos == L {305+ count++306+ return307+ }308+ for v := int8(0); v <= 1; v++ {309+ if pos >= 2 && a == v && b == v {310+ continue311+ }312+ rec(pos+1, ones+int(v), b, v)313+ }314+ }315+ rec(0, 0, -1, -1)316+ distinctCache[L] = count317+ return count318+}319+320+// normalise trims a tile's bounding box to its cells and recounts.321+func (t *Tile) normalise() {322+ minX, minY, maxX, maxY := 1<<30, 1<<30, -1, -1323+ for _, r := range t.Rects {324+ minX = min(minX, r[0])325+ minY = min(minY, r[1])326+ maxX = max(maxX, r[0]+r[2])327+ maxY = max(maxY, r[1]+r[3])328+ }329+ for i := range t.Rects {330+ t.Rects[i][0] -= minX331+ t.Rects[i][1] -= minY332+ }333+ t.X0 += minX334+ t.Y0 += minY335+ t.W, t.H = maxX-minX, maxY-minY336+ t.Cells = 0337+ for _, m := range t.Mask() {338+ if m {339+ t.Cells++340+ }341+ }342+ t.M = MFor(t.Cells)343+}344+345+// globalCells lists the tile's cells as field ids y*G+x.346+func (t Tile) globalCells(G int) []int {347+ mask := t.Mask()348+ out := make([]int, 0, t.Cells)349+ for ly := 0; ly < t.H; ly++ {350+ for lx := 0; lx < t.W; lx++ {351+ if mask[ly*t.W+lx] {352+ out = append(out, (t.Y0+ly)*G+t.X0+lx)353+ }354+ }355+ }356+ return out357+}358+359+// globalLines is Lines in field ids.360+func (t Tile) globalLines(G int) [][]int {361+ lines := t.Lines()362+ out := make([][]int, len(lines))363+ for i, run := range lines {364+ out[i] = make([]int, len(run))365+ for k, li := range run {366+ out[i][k] = (t.Y0+li/t.W)*G + t.X0 + li%t.W367+ }368+ }369+ return out370+}371+372+// ---------------------------------------------------------------- layout373+374+// Owners returns, per field cell, the ids of the tiles containing it.375+func (L *Layout) Owners() [][]int {376+ own := make([][]int, L.G*L.G)377+ for _, t := range L.Tiles {378+ for _, id := range t.globalCells(L.G) {379+ own[id] = append(own[id], t.ID)380+ }381+ }382+ return own383+}384+385+// Neighbours lists the tiles sharing at least one cell with t.386+func (L *Layout) Neighbours(t int) []int {387+ out := make([]int, 0, len(L.Overlaps[t]))388+ for _, o := range L.Overlaps[t] {389+ out = append(out, o.With)390+ }391+ return out392+}393+394+func overlaps(L *Layout) [][]Overlap {395+ own := L.Owners()396+ shared := map[[2]int][]int{}397+ for id, os := range own {398+ for i := 0; i < len(os); i++ {399+ for j := i + 1; j < len(os); j++ {400+ a, b := os[i], os[j]401+ if a > b {402+ a, b = b, a403+ }404+ shared[[2]int{a, b}] = append(shared[[2]int{a, b}], id)405+ }406+ }407+ }408+ out := make([][]Overlap, len(L.Tiles))409+ for k, ids := range shared {410+ sort.Ints(ids)411+ out[k[0]] = append(out[k[0]], Overlap{With: k[1], Cells: len(ids), Ids: ids})412+ out[k[1]] = append(out[k[1]], Overlap{With: k[0], Cells: len(ids), Ids: ids})413+ }414+ for i := range out {415+ sort.Slice(out[i], func(a, b int) bool { return out[i][a].With < out[i][b].With })416+ }417+ return out418+}419+420+func report(L *Layout) Report {421+ G := L.G422+ r := Report{Tiles: len(L.Tiles), ByCells: map[string]int{}}423+ own := L.Owners()424+ covered := 0425+ for _, os := range own {426+ if len(os) > 0 {427+ covered++428+ }429+ }430+ r.Coverage = float64(covered) / float64(G*G)431+ for _, t := range L.Tiles {432+ r.ByCells[bucket(t.Cells)]++433+ if len(t.Rects) > 1 && t.Cells != t.W*t.H {434+ r.Compound++435+ }436+ }437+ total := 0438+ for _, os := range L.Overlaps {439+ for _, o := range os {440+ r.Overlaps++441+ total += o.Cells442+ }443+ }444+ r.Overlaps /= 2445+ if r.Overlaps > 0 {446+ r.MeanOverlap = float64(total) / float64(2*r.Overlaps)447+ }448+ for _, h := range holesOf(own, G, L.Tiles) {449+ r.Holes++450+ r.HoleCells += len(h.cells)451+ }452+ if r.Holes > 0 {453+ r.MeanHole = float64(r.HoleCells) / float64(r.Holes)454+ }455+ return r456+}457+458+// hole is a 4-connected component of uncovered cells inside the cluster's459+// bounding box, with its own bounding box.460+type hole struct {461+ cells []int462+ x0, y0, x1, y1 int463+}464+465+func holesOf(own [][]int, G int, tiles []Tile) []hole {466+ bx0, by0, bx1, by1 := G, G, 0, 0467+ for _, t := range tiles {468+ bx0, by0 = min(bx0, t.X0), min(by0, t.Y0)469+ bx1, by1 = max(bx1, t.X0+t.W), max(by1, t.Y0+t.H)470+ }471+ seen := make([]bool, G*G)472+ var out []hole473+ for y := by0; y < by1; y++ {474+ for x := bx0; x < bx1; x++ {475+ start := y*G + x476+ if len(own[start]) > 0 || seen[start] {477+ continue478+ }479+ h := hole{x0: G, y0: G}480+ stack := []int{start}481+ seen[start] = true482+ for len(stack) > 0 {483+ c := stack[len(stack)-1]484+ stack = stack[:len(stack)-1]485+ h.cells = append(h.cells, c)486+ cx, cy := c%G, c/G487+ h.x0, h.y0 = min(h.x0, cx), min(h.y0, cy)488+ h.x1, h.y1 = max(h.x1, cx+1), max(h.y1, cy+1)489+ for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {490+ nx, ny := cx+d[0], cy+d[1]491+ if nx < bx0 || ny < by0 || nx >= bx1 || ny >= by1 {492+ continue493+ }494+ id := ny*G + nx495+ if !seen[id] && len(own[id]) == 0 {496+ seen[id] = true497+ stack = append(stack, id)498+ }499+ }500+ }501+ sort.Ints(h.cells)502+ out = append(out, h)503+ }504+ }505+ return out506+}507+508+func bucket(cells int) string {509+ switch {510+ case cells <= 32:511+ return "16-32"512+ case cells <= 64:513+ return "33-64"514+ case cells <= 128:515+ return "65-128"516+ default:517+ return "129-256"518+ }519+}520+521+// Pack builds a layout. Errors only on bad parameters.522+func Pack(p Params) (*Layout, error) {523+ if p.Kind == "" {524+ p.Kind = "scatter"525+ }526+ if p.G < MinN {527+ return nil, fmt.Errorf("G must be at least %d", MinN)528+ }529+ rng := rngFor("pack", p.Seed, p.Kind, fmt.Sprint(p.G))530+ L := &Layout{G: p.G, Kind: p.Kind}531+ switch p.Kind {532+ case "scatter":533+ if err := scatter(L, p, rng); err != nil {534+ return nil, err535+ }536+ case "grid", "bands", "big-first":537+ squares, err := squarePack(p, rng)538+ if err != nil {539+ return nil, err540+ }541+ for i, r := range squares {542+ n := r.W + 1543+ t := Tile{ID: i, X0: r.X0, Y0: r.Y0, W: n, H: n, Rects: [][4]int{{0, 0, n, n}}, Cells: n * n, M: MFor(n * n)}544+ L.Tiles = append(L.Tiles, t)545+ }546+ L.SeedTile = rng.IntN(len(L.Tiles))547+ default:548+ return nil, fmt.Errorf("unknown packer %q", p.Kind)549+ }550+ L.Overlaps = overlaps(L)551+ rep := report(L)552+ rep.Stopped, rep.MaxHole, rep.Filled = L.Report.Stopped, L.Report.MaxHole, L.Report.Filled553+ L.Report = rep554+ return L, nil555+}556+557+// squarePack runs the square kinds in pitch space (a tile of side n is a558+// square of side n-1; tiles are disjoint there and share a border of cells559+// in field space) and returns the squares sorted by y then x.560+func squarePack(p Params, rng *rand.Rand) ([]Rect, error) {561+ S := p.G - 1562+ var squares []Rect563+ switch p.Kind {564+ case "grid":565+ if p.N < MinN || p.N > MaxN || p.N%2 != 0 {566+ return nil, fmt.Errorf("grid needs even n in %d..%d", MinN, MaxN)567+ }568+ s := p.N - 1569+ if S%s != 0 {570+ return nil, fmt.Errorf("G-1 = %d is not a multiple of n-1 = %d", S, s)571+ }572+ for y := 0; y < S; y += s {573+ for x := 0; x < S; x += s {574+ squares = append(squares, Rect{x, y, s, s})575+ }576+ }577+ case "bands":578+ squares = fillRect(Rect{0, 0, S, S}, rng, nil)579+ case "big-first":580+ if p.BigArea == 0 {581+ p.BigArea = 0.45582+ }583+ if len(p.BigSizes) == 0 {584+ p.BigSizes = []int{14, 16}585+ }586+ squares = bigFirst(S, p, rng)587+ }588+ sort.Slice(squares, func(i, j int) bool {589+ if squares[i].Y0 != squares[j].Y0 {590+ return squares[i].Y0 < squares[j].Y0591+ }592+ return squares[i].X0 < squares[j].X0593+ })594+ return squares, nil595+}596+597+// ---------------------------------------------------------------- scatter598+599+// scatterState is the growing cluster: an owner index and, per placed600+// tile, its cells and lines in field ids.601+type scatterState struct {602+ G int603+ own [][]int604+ cells [][]int605+ lines [][][]int606+ stamp []int // per cell, the candidate id that last covered it607+ cand int608+}609+610+func (s *scatterState) place(t Tile) {611+ cells := t.globalCells(s.G)612+ for _, id := range cells {613+ s.own[id] = append(s.own[id], t.ID)614+ }615+ s.cells = append(s.cells, cells)616+ s.lines = append(s.lines, t.globalLines(s.G))617+}618+619+// geom is a shape's cells and lines as local indexes, computed once per620+// shape and translated per candidate position.621+type geom struct {622+ cells []int623+ lines [][]int624+}625+626+func geomOf(t Tile) geom {627+ mask := t.Mask()628+ g := geom{lines: maskLines(mask, t.W, t.H)}629+ for i, in := range mask {630+ if in {631+ g.cells = append(g.cells, i)632+ }633+ }634+ return g635+}636+637+// accepts applies the placement rule to a candidate against the placed638+// tiles: inside the field, touching the cluster, and for every touched639+// tile the shared cells number at least MinLine, contain a whole line640+// (length >= MinLine) of either tile, are at most a quarter of the smaller641+// tile, and add at most two cells that would have three or more owners.642+// Containment is impossible under the quarter rule.643+func (s *scatterState) accepts(t Tile, placed []Tile) (bool, string) {644+ return s.acceptsAs(t, geomOf(t), placed, false)645+}646+647+// acceptsAs is accepts with the fill-pass variant: a filler additionally648+// keeps at least half of its cells unshared in total (pinned on two or649+// three sides it is still a puzzle), while the per-neighbour quarter cap650+// and the whole-line requirement stay. g is the candidate's geometry.651+func (s *scatterState) acceptsAs(t Tile, g geom, placed []Tile, filler bool) (bool, string) {652+ if t.X0 < 0 || t.Y0 < 0 || t.X0+t.W > s.G || t.Y0+t.H > s.G {653+ return false, "outside"654+ }655+ if s.stamp == nil {656+ s.stamp = make([]int, s.G*s.G)657+ }658+ s.cand++659+ at := func(li int) int { return (t.Y0+li/t.W)*s.G + t.X0 + li%t.W }660+ // shared cells per touched tile, and cells that would get a third owner661+ type touch struct{ id, cells, triple int }662+ var touched []touch663+ total := 0664+ for _, li := range g.cells {665+ id := at(li)666+ s.stamp[id] = s.cand667+ os := s.own[id]668+ if len(os) > 0 {669+ total++670+ }671+ for _, o := range os {672+ k := -1673+ for i := range touched {674+ if touched[i].id == o {675+ k = i676+ break677+ }678+ }679+ if k < 0 {680+ touched = append(touched, touch{id: o})681+ k = len(touched) - 1682+ }683+ touched[k].cells++684+ if len(os) >= 2 {685+ touched[k].triple++686+ }687+ }688+ }689+ if len(touched) == 0 {690+ return false, "isolated"691+ }692+ if filler && total*2 > t.Cells {693+ return false, "filler more than half shared"694+ }695+ for _, tc := range touched {696+ if tc.cells < MinLine {697+ return false, "overlap too small"698+ }699+ smaller := min(t.Cells, placed[tc.id].Cells)700+ if tc.cells*4 > smaller {701+ return false, "overlap too large"702+ }703+ if tc.cells == smaller {704+ return false, "containment"705+ }706+ if tc.triple > 2 {707+ return false, "too many junction cells"708+ }709+ full := false710+ for _, run := range g.lines {711+ if len(run) < MinLine {712+ continue713+ }714+ all := true715+ for _, li := range run {716+ if !s.ownedBy(at(li), tc.id) {717+ all = false718+ break719+ }720+ }721+ if all {722+ full = true723+ break724+ }725+ }726+ if !full {727+ for _, run := range s.lines[tc.id] {728+ if len(run) < MinLine {729+ continue730+ }731+ all := true732+ for _, id := range run {733+ if s.stamp[id] != s.cand {734+ all = false735+ break736+ }737+ }738+ if all {739+ full = true740+ break741+ }742+ }743+ }744+ if !full {745+ return false, "no whole line in overlap"746+ }747+ }748+ return true, ""749+}750+751+func (s *scatterState) ownedBy(id, o int) bool {752+ for _, w := range s.own[id] {753+ if w == o {754+ return true755+ }756+ }757+ return false758+}759+760+// drawShape draws a shape at the origin: an even-sided rectangle, or with761+// probability compound the union of two intersecting even-sided rectangles762+// at an even offset (L, T, plus, stepped). Sizes keep the cell count in763+// [minCells, maxCells].764+func drawShape(rng *rand.Rand, minCells, maxCells int, compound float64) Tile {765+ even := func(lo, hi int) int { return lo + 2*rng.IntN((hi-lo)/2+1) }766+ for {767+ w, h := even(MinLine, MaxN), even(MinLine, MaxN)768+ t := Tile{Rects: [][4]int{{0, 0, w, h}}}769+ if rng.Float64() < compound {770+ w2, h2 := even(MinLine, MaxN), even(MinLine, MaxN)771+ // even offset such that the rectangles intersect772+ dx := even(-(w2 - 2), w-2)773+ dy := even(-(h2 - 2), h-2)774+ t.Rects = append(t.Rects, [4]int{dx, dy, w2, h2})775+ }776+ t.normalise()777+ if t.W > MaxN || t.H > MaxN || t.Cells < minCells || t.Cells > maxCells {778+ continue779+ }780+ if ValidShape(t.Mask(), t.W, t.H) != nil {781+ continue782+ }783+ return t784+ }785+}786+787+// scatter grows one cluster from the seed tile: every drop is anchored to788+// a random placed tile so its bounding box overlaps that tile's, at a789+// jittered offset, and is kept when accepts says so. Stops at the coverage790+// target or after maxReject consecutive rejections.791+func scatter(L *Layout, p Params, rng *rand.Rand) error {792+ G := p.G793+ if p.Coverage == 0 {794+ p.Coverage = 0.72795+ }796+ if p.MinCells == 0 {797+ p.MinCells = 16798+ }799+ if p.MaxCells == 0 {800+ p.MaxCells = 256801+ }802+ if p.Compound == 0 {803+ p.Compound = 0.4804+ }805+ if p.MaxHole == 0 {806+ p.MaxHole = 24807+ }808+ if p.MinCells < MinLine*MinLine || p.MaxCells > MaxN*MaxN || p.MinCells > p.MaxCells {809+ return fmt.Errorf("scatter cell range %d..%d out of %d..%d", p.MinCells, p.MaxCells, MinLine*MinLine, MaxN*MaxN)810+ }811+ if G < MaxN {812+ return fmt.Errorf("scatter needs G >= %d", MaxN)813+ }814+ const maxReject = 4000815+ st := &scatterState{G: G, own: make([][]int, G*G)}816+ seed := drawShape(rng, p.MinCells, p.MaxCells, p.Compound)817+ seed.ID = 0818+ seed.X0 = rng.IntN(G - seed.W + 1)819+ seed.Y0 = rng.IntN(G - seed.H + 1)820+ L.Tiles = append(L.Tiles, seed)821+ st.place(seed)822+ L.SeedTile = 0823+ covered := seed.Cells824+ target := int(p.Coverage * float64(G*G))825+ rejects := 0826+ for covered < target {827+ if rejects >= maxReject {828+ L.Report.Stopped = "rejections"829+ break830+ }831+ // compound drops are rejected more often than rectangles, so the832+ // draw probability is steered by the accepted share833+ pc := p.Compound834+ if n := len(L.Tiles); n >= 5 {835+ share := max(0.05, float64(compoundCount(L.Tiles))/float64(n))836+ pc = min(0.95, max(0.05, p.Compound*p.Compound/share))837+ }838+ t := drawShape(rng, p.MinCells, p.MaxCells, pc)839+ a := L.Tiles[rng.IntN(len(L.Tiles))]840+ // bounding boxes overlap: X0 in [a.X0-t.W+1, a.X0+a.W-1]841+ t.X0 = a.X0 - t.W + 1 + rng.IntN(a.W+t.W-1)842+ t.Y0 = a.Y0 - t.H + 1 + rng.IntN(a.H+t.H-1)843+ t.ID = len(L.Tiles)844+ ok, _ := st.accepts(t, L.Tiles)845+ if !ok {846+ rejects++847+ continue848+ }849+ rejects = 0850+ L.Tiles = append(L.Tiles, t)851+ st.place(t)852+ for _, id := range st.cells[t.ID] {853+ if len(st.own[id]) == 1 {854+ covered++855+ }856+ }857+ }858+ if L.Report.Stopped == "" {859+ L.Report.Stopped = "coverage"860+ }861+ L.Report.MaxHole = p.MaxHole862+ L.Report.Filled = fillHoles(L, p, st, rng)863+ return nil864+}865+866+// fillShapes are the small valid shapes the fill pass draws from, largest867+// first: 8x8, 8x6, 6x8, 6x6, the notched 6x6 (four corners), and every L868+// or T union of two of those rectangles at an even offset that fits a869+// 12x12 box, deduplicated by mask.870+var fillShapes = func() []Tile {871+ bases := [][2]int{{8, 8}, {8, 6}, {6, 8}, {6, 6}}872+ seen := map[string]bool{}873+ var out []Tile874+ add := func(rects [][4]int) {875+ t := Tile{Rects: rects}876+ t.normalise()877+ if t.W > 12 || t.H > 12 || ValidShape(t.Mask(), t.W, t.H) != nil {878+ return879+ }880+ key := fmt.Sprint(t.W, t.H, t.Mask())881+ if seen[key] {882+ return883+ }884+ seen[key] = true885+ out = append(out, t)886+ }887+ for _, b := range bases {888+ add([][4]int{{0, 0, b[0], b[1]}})889+ for _, c := range bases {890+ for dx := -(c[0] - 2); dx <= b[0]-2; dx += 2 {891+ for dy := -(c[1] - 2); dy <= b[1]-2; dy += 2 {892+ add([][4]int{{0, 0, b[0], b[1]}, {dx, dy, c[0], c[1]}})893+ }894+ }895+ }896+ }897+ for _, corner := range [][4]int{{0, 0, 2, 2}, {4, 0, 2, 2}, {0, 4, 2, 2}, {4, 4, 2, 2}} {898+ // 6x6 minus a 2x2 corner, as two overlapping rectangles899+ var rects [][4]int900+ if corner[1] == 0 {901+ rects = [][4]int{{0, 2, 6, 4}, {2 - corner[0]/2, 0, 4, 6}}902+ } else {903+ rects = [][4]int{{0, 0, 6, 4}, {2 - corner[0]/2, 0, 4, 6}}904+ }905+ add(rects)906+ }907+ sort.SliceStable(out, func(i, j int) bool { return out[i].Cells > out[j].Cells })908+ return out909+}()910+911+var fillGeoms = func() []geom {912+ out := make([]geom, len(fillShapes))913+ for i, sh := range fillShapes {914+ out[i] = geomOf(sh)915+ }916+ return out917+}()918+919+// fillHoles covers every hole larger than MaxHole with small shapes920+// (fillShapes, largest that can land first) placed so the shape covers a921+// cell of the hole and is accepted under the filler rule (acceptsAs),922+// until every remaining hole is at most MaxHole or a hole takes no shape923+// at any position. Holes at or under MaxHole are left alone: they are the924+// gaps. Positions are enumerated, not sampled: for each hole cell in a925+// seeded order, every offset that puts the shape over that cell, with a926+// budget per shape. After a placement the hole is re-flooded locally (it927+// may have split) and the pieces go back on the work list. Returns the928+// number of tiles added.929+func fillHoles(L *Layout, p Params, st *scatterState, rng *rand.Rand) int {930+ const perShape = 120 // position evaluations per shape and hole931+ G := L.G932+ added := 0933+ bx0, by0, bx1, by1 := clusterBox(L.Tiles, G)934+ work := holesOf(st.own, G, L.Tiles)935+ for len(work) > 0 {936+ h := work[len(work)-1]937+ work = work[:len(work)-1]938+ if len(h.cells) <= p.MaxHole {939+ continue940+ }941+ order := append([]int(nil), h.cells...)942+ rng.Shuffle(len(order), func(a, b int) { order[a], order[b] = order[b], order[a] })943+ placed := false944+ shapes:945+ for si, shape := range fillShapes {946+ // a filler keeps at least half of its cells unshared, so it947+ // needs at least that many hole cells to land on948+ if shape.Cells > 2*len(h.cells) {949+ continue950+ }951+ sg := fillGeoms[si]952+ evals := 0953+ for _, c := range order {954+ cx, cy := c%G, c/G955+ for _, li := range sg.cells {956+ evals++957+ if evals > perShape {958+ continue shapes959+ }960+ t := shape961+ t.Rects = append([][4]int(nil), shape.Rects...)962+ t.ID = len(L.Tiles)963+ t.X0, t.Y0 = cx-li%shape.W, cy-li/shape.W964+ if ok, _ := st.acceptsAs(t, sg, L.Tiles, true); !ok {965+ continue966+ }967+ L.Tiles = append(L.Tiles, t)968+ st.place(t)969+ added++970+ placed = true971+ break shapes972+ }973+ }974+ }975+ if !placed {976+ continue // stuck: geometry admits no filler here977+ }978+ // re-flood what is left of this hole; it may have split979+ seen := map[int]bool{}980+ for _, c := range h.cells {981+ if len(st.own[c]) > 0 || seen[c] {982+ continue983+ }984+ piece := floodHole(st.own, G, c, bx0, by0, bx1, by1, seen)985+ work = append(work, piece)986+ }987+ }988+ return added989+}990+991+func clusterBox(tiles []Tile, G int) (x0, y0, x1, y1 int) {992+ x0, y0, x1, y1 = G, G, 0, 0993+ for _, t := range tiles {994+ x0, y0 = min(x0, t.X0), min(y0, t.Y0)995+ x1, y1 = max(x1, t.X0+t.W), max(y1, t.Y0+t.H)996+ }997+ return998+}999+1000+// floodHole collects the uncovered 4-connected component containing start,1001+// within the box, marking seen.1002+func floodHole(own [][]int, G, start, bx0, by0, bx1, by1 int, seen map[int]bool) hole {1003+ h := hole{x0: G, y0: G}1004+ stack := []int{start}1005+ seen[start] = true1006+ for len(stack) > 0 {1007+ c := stack[len(stack)-1]1008+ stack = stack[:len(stack)-1]1009+ h.cells = append(h.cells, c)1010+ cx, cy := c%G, c/G1011+ h.x0, h.y0 = min(h.x0, cx), min(h.y0, cy)1012+ h.x1, h.y1 = max(h.x1, cx+1), max(h.y1, cy+1)1013+ for _, d := range [][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} {1014+ nx, ny := cx+d[0], cy+d[1]1015+ if nx < bx0 || ny < by0 || nx >= bx1 || ny >= by1 {1016+ continue1017+ }1018+ id := ny*G + nx1019+ if !seen[id] && len(own[id]) == 0 {1020+ seen[id] = true1021+ stack = append(stack, id)1022+ }1023+ }1024+ }1025+ sort.Ints(h.cells)1026+ return h1027+}1028+1029+// UnfillableHoles counts the holes over MaxHole whose centroid lies inside1030+// the convex hull of the tiles' corners: pockets the geometry could not1031+// close (holes outside the hull are the field's ragged edge, not gaps).1032+func (L *Layout) UnfillableHoles(maxHole int) int {1033+ var pts [][2]int1034+ for _, t := range L.Tiles {1035+ pts = append(pts, [2]int{t.X0, t.Y0}, [2]int{t.X0 + t.W, t.Y0}, [2]int{t.X0, t.Y0 + t.H}, [2]int{t.X0 + t.W, t.Y0 + t.H})1036+ }1037+ hull := convexHull(pts)1038+ n := 01039+ for _, h := range holesOf(L.Owners(), L.G, L.Tiles) {1040+ if len(h.cells) <= maxHole {1041+ continue1042+ }1043+ sx, sy := 0.0, 0.01044+ for _, c := range h.cells {1045+ sx += float64(c%L.G) + 0.51046+ sy += float64(c/L.G) + 0.51047+ }1048+ if insideHull(hull, sx/float64(len(h.cells)), sy/float64(len(h.cells))) {1049+ n++1050+ }1051+ }1052+ return n1053+}1054+1055+func convexHull(pts [][2]int) [][2]int {1056+ sort.Slice(pts, func(i, j int) bool {1057+ if pts[i][0] != pts[j][0] {1058+ return pts[i][0] < pts[j][0]1059+ }1060+ return pts[i][1] < pts[j][1]1061+ })1062+ cross := func(o, a, b [2]int) int { return (a[0]-o[0])*(b[1]-o[1]) - (a[1]-o[1])*(b[0]-o[0]) }1063+ var lower, upper [][2]int1064+ for _, p := range pts {1065+ for len(lower) >= 2 && cross(lower[len(lower)-2], lower[len(lower)-1], p) <= 0 {1066+ lower = lower[:len(lower)-1]1067+ }1068+ lower = append(lower, p)1069+ }1070+ for i := len(pts) - 1; i >= 0; i-- {1071+ p := pts[i]1072+ for len(upper) >= 2 && cross(upper[len(upper)-2], upper[len(upper)-1], p) <= 0 {1073+ upper = upper[:len(upper)-1]1074+ }1075+ upper = append(upper, p)1076+ }1077+ return append(lower[:len(lower)-1], upper[:len(upper)-1]...)1078+}1079+1080+func insideHull(hull [][2]int, x, y float64) bool {1081+ for i := range hull {1082+ a, b := hull[i], hull[(i+1)%len(hull)]1083+ if (float64(b[0]-a[0]))*(y-float64(a[1]))-(float64(b[1]-a[1]))*(x-float64(a[0])) < 0 {1084+ return false1085+ }1086+ }1087+ return true1088+}1089+1090+func compoundCount(tiles []Tile) int {1091+ n := 01092+ for _, t := range tiles {1093+ if t.Cells != t.W*t.H {1094+ n++1095+ }1096+ }1097+ return n1098+}1099+1100+// ---------------------------------------------------------------- squares1101+1102+// bigFirst drops large squares at seeded positions until BigArea of the1103+// board is covered (or placements stop fitting), then fills the remaining1104+// guillotine leaves with bands.1105+func bigFirst(S int, p Params, rng *rand.Rand) []Rect {1106+ leaves := []Rect{{0, 0, S, S}}1107+ var placed []Rect1108+ covered := 01109+ target := int(p.BigArea * float64(S*S))1110+ misses := 01111+ for covered < target && misses < 400 {1112+ n := p.BigSizes[rng.IntN(len(p.BigSizes))]1113+ s := n - 11114+ var fit []int1115+ for i, l := range leaves {1116+ if l.W >= s && l.H >= s && splittable(l.W-s) && splittable(l.H-s) {1117+ fit = append(fit, i)1118+ }1119+ }1120+ if len(fit) == 0 {1121+ misses++1122+ continue1123+ }1124+ i := fit[rng.IntN(len(fit))]1125+ l := leaves[i]1126+ corner := func(max int) int {1127+ switch rng.IntN(4) {1128+ case 0:1129+ return snap(rng.IntN(max+1), max)1130+ case 1:1131+ return max1132+ default:1133+ return 01134+ }1135+ }1136+ x := l.X0 + corner(l.W-s)1137+ y := l.Y0 + corner(l.H-s)1138+ sq := Rect{x, y, s, s}1139+ placed = append(placed, sq)1140+ covered += s * s1141+ rest := []Rect{}1142+ if x > l.X0 {1143+ rest = append(rest, Rect{l.X0, l.Y0, x - l.X0, l.H})1144+ }1145+ if x+s < l.X0+l.W {1146+ rest = append(rest, Rect{x + s, l.Y0, l.X0 + l.W - (x + s), l.H})1147+ }1148+ if y > l.Y0 {1149+ rest = append(rest, Rect{x, l.Y0, s, y - l.Y0})1150+ }1151+ if y+s < l.Y0+l.H {1152+ rest = append(rest, Rect{x, y + s, s, l.Y0 + l.H - (y + s)})1153+ }1154+ leaves = append(leaves[:i], leaves[i+1:]...)1155+ leaves = append(leaves, rest...)1156+ }1157+ for _, l := range leaves {1158+ placed = fillRect(l, rng, placed)1159+ }1160+ return placed1161+}1162+1163+// splittable reports whether a leftover length max can be divided into two1164+// representable parts (either may be zero).1165+func splittable(max int) bool {1166+ for x := 0; x <= max; x++ {1167+ if representable(x) && representable(max-x) {1168+ return true1169+ }1170+ }1171+ return false1172+}1173+1174+// snap keeps an offset at 0, max, or a representable length in between so1175+// the strips left beside a big square stay tileable more often than not.1176+func snap(v, max int) int {1177+ if v <= 2 {1178+ return 01179+ }1180+ if v >= max-2 {1181+ return max1182+ }1183+ for d := 0; d <= max; d++ {1184+ for _, x := range []int{v - d, v + d} {1185+ if x >= 0 && x <= max && representable(x) && representable(max-x) {1186+ return x1187+ }1188+ }1189+ }1190+ return 01191+}1192+1193+// representable reports whether a length can be exactly filled by odd1194+// sides 5..15: every odd length from 5 and every even length from 10.1195+func representable(x int) bool {1196+ return x == 0 || (x >= MinN-1 && x%2 == 1) || (x >= 2*(MinN-1) && x%2 == 0)1197+}1198+1199+// fillRect tiles a pitch-space rectangle with squares of odd side 5..15 by1200+// bands: a band of height b runs along the longer side and is filled with1201+// b-squares; the leftover column is cut by Euclid; the rest recurses.1202+// Pieces narrower than 5 are left uncovered.1203+func fillRect(r Rect, rng *rand.Rand, out []Rect) []Rect {1204+ if r.W < MinN-1 || r.H < MinN-1 {1205+ return out1206+ }1207+ w, h := r.W, r.H1208+ horizontal := w >= h1209+ long, short := w, h1210+ if !horizontal {1211+ long, short = h, w1212+ }1213+ b := pickBand(short, long, rng)1214+ k := long / b1215+ rem := long - k*b1216+ for i := 0; i < k; i++ {1217+ if horizontal {1218+ out = append(out, Rect{r.X0 + i*b, r.Y0, b, b})1219+ } else {1220+ out = append(out, Rect{r.X0, r.Y0 + i*b, b, b})1221+ }1222+ }1223+ if rem > 0 {1224+ if horizontal {1225+ out = fillRect(Rect{r.X0 + k*b, r.Y0, rem, b}, rng, out)1226+ } else {1227+ out = fillRect(Rect{r.X0, r.Y0 + k*b, b, rem}, rng, out)1228+ }1229+ }1230+ if short > b {1231+ if horizontal {1232+ out = fillRect(Rect{r.X0, r.Y0 + b, w, h - b}, rng, out)1233+ } else {1234+ out = fillRect(Rect{r.X0 + b, r.Y0, w - b, h}, rng, out)1235+ }1236+ }1237+ return out1238+}1239+1240+// pickBand chooses a band size b in 5..min(15, short) preferring values1241+// whose remainders (short-b and long mod b) are 0 or at least 5, so the1242+// leftovers stay tileable.1243+func pickBand(short, long int, rng *rand.Rand) int {1244+ max := short1245+ if max > MaxN-1 {1246+ max = MaxN - 11247+ }1248+ if max%2 == 0 {1249+ max--1250+ }1251+ good := []int{}1252+ for b := MinN - 1; b <= max; b += 2 {1253+ if representable(short-b) && representable(long%b) {1254+ good = append(good, b)1255+ }1256+ }1257+ if len(good) == 0 {1258+ for b := MinN - 1; b <= max; b += 2 {1259+ if representable(short - b) {1260+ good = append(good, b)1261+ }1262+ }1263+ }1264+ if len(good) == 0 {1265+ return max1266+ }1267+ i := int(float64(len(good)) * (1 - rng.Float64()*rng.Float64()))1268+ if i >= len(good) {1269+ i = len(good) - 11270+ }1271+ return good[i]1272+}@@ -0,0 +1,355 @@
1+package pack2+3+import (4+ "encoding/json"5+ "testing"6+ "time"7+)8+9+// check verifies what every layout must satisfy: valid shapes inside the10+// field, consistent owners/overlaps/neighbours, m by cell count.11+func check(t *testing.T, L *Layout) {12+ t.Helper()13+ G := L.G14+ own := L.Owners()15+ for _, tl := range L.Tiles {16+ if tl.X0 < 0 || tl.Y0 < 0 || tl.X0+tl.W > G || tl.Y0+tl.H > G {17+ t.Fatalf("tile %d out of the field", tl.ID)18+ }19+ if err := ValidShape(tl.Mask(), tl.W, tl.H); err != nil {20+ t.Fatalf("tile %d (%dx%d rects %v): %v", tl.ID, tl.W, tl.H, tl.Rects, err)21+ }22+ if tl.M != MFor(tl.Cells) {23+ t.Fatalf("tile %d m=%d want %d", tl.ID, tl.M, MFor(tl.Cells))24+ }25+ n := 026+ for _, m := range tl.Mask() {27+ if m {28+ n++29+ }30+ }31+ if n != tl.Cells {32+ t.Fatalf("tile %d: cells %d, mask has %d", tl.ID, tl.Cells, n)33+ }34+ }35+ // overlaps: symmetric, cell counts match the owner index, neighbours agree36+ for _, tl := range L.Tiles {37+ seen := map[int]bool{}38+ for _, o := range L.Overlaps[tl.ID] {39+ if seen[o.With] || o.With == tl.ID {40+ t.Fatalf("tile %d: overlap list not a set", tl.ID)41+ }42+ seen[o.With] = true43+ if o.Cells != len(o.Ids) || o.Cells == 0 {44+ t.Fatalf("tile %d/%d: cells %d ids %d", tl.ID, o.With, o.Cells, len(o.Ids))45+ }46+ for _, id := range o.Ids {47+ a, b := false, false48+ for _, w := range own[id] {49+ a = a || w == tl.ID50+ b = b || w == o.With51+ }52+ if !a || !b {53+ t.Fatalf("tile %d/%d: cell %d not owned by both", tl.ID, o.With, id)54+ }55+ }56+ back := false57+ for _, o2 := range L.Overlaps[o.With] {58+ if o2.With == tl.ID && o2.Cells == o.Cells {59+ back = true60+ }61+ }62+ if !back {63+ t.Fatalf("overlap %d/%d has no mirror", tl.ID, o.With)64+ }65+ }66+ if len(L.Neighbours(tl.ID)) != len(L.Overlaps[tl.ID]) {67+ t.Fatalf("tile %d: neighbours vs overlaps", tl.ID)68+ }69+ }70+ // every shared cell is in exactly the overlap lists of its owner pairs71+ for id, os := range own {72+ for i := 0; i < len(os); i++ {73+ for j := i + 1; j < len(os); j++ {74+ found := false75+ for _, o := range L.Overlaps[os[i]] {76+ if o.With == os[j] {77+ for _, c := range o.Ids {78+ found = found || c == id79+ }80+ }81+ }82+ if !found {83+ t.Fatalf("cell %d shared by %d and %d but not in their overlap", id, os[i], os[j])84+ }85+ }86+ }87+ }88+ if L.SeedTile < 0 || L.SeedTile >= len(L.Tiles) {89+ t.Fatal("seed tile out of range")90+ }91+}92+93+func TestDistinctLines(t *testing.T) {94+ want := map[int]int{4: 6, 6: 14, 8: 34, 10: 84, 12: 208, 14: 518, 16: 1296}95+ for L, n := range want {96+ if got := DistinctLines(L); got != n {97+ t.Errorf("D(%d) = %d, want %d", L, got, n)98+ }99+ }100+ // 16 rows of length 6 need more than 14/2 distinct lines101+ if ValidShape(Tile{W: 6, H: 16, Rects: [][4]int{{0, 0, 6, 16}}}.Mask(), 6, 16) == nil {102+ t.Fatal("6x16 strip accepted")103+ }104+ // 4 wide by 6 tall: 6 rows of length 4 > 6/2105+ if ValidShape(Tile{W: 4, H: 6, Rects: [][4]int{{0, 0, 4, 6}}}.Mask(), 4, 6) == nil {106+ t.Fatal("4x6 strip accepted")107+ }108+ // 16x16: 16 lines of length 16 <= 648109+ if err := ValidShape(Tile{W: 16, H: 16, Rects: [][4]int{{0, 0, 16, 16}}}.Mask(), 16, 16); err != nil {110+ t.Fatal(err)111+ }112+ // 6x14: 14 rows of 6 exactly fails (14 > 7); 6x6 passes (6 <= 7)113+ if ValidShape(Tile{W: 6, H: 14, Rects: [][4]int{{0, 0, 6, 14}}}.Mask(), 6, 14) == nil {114+ t.Fatal("6x14 accepted")115+ }116+}117+118+func TestFillShapes(t *testing.T) {119+ if len(fillShapes) < 8 {120+ t.Fatalf("only %d fill shapes", len(fillShapes))121+ }122+ for i, sh := range fillShapes {123+ if err := ValidShape(sh.Mask(), sh.W, sh.H); err != nil {124+ t.Fatal(err)125+ }126+ if i > 0 && fillShapes[i-1].Cells < sh.Cells {127+ t.Fatal("fill shapes not largest first")128+ }129+ }130+ cells := map[int]bool{}131+ for _, sh := range fillShapes {132+ cells[sh.Cells] = true133+ }134+ // 8x6 is not valid (8 parallel lines of length 6 > 7), so no 48135+ for _, want := range []int{64, 36, 32} {136+ if !cells[want] {137+ t.Errorf("no fill shape with %d cells", want)138+ }139+ }140+ t.Logf("%d fill shapes, %d..%d cells", len(fillShapes), fillShapes[len(fillShapes)-1].Cells, fillShapes[0].Cells)141+}142+143+func TestShapesAndLines(t *testing.T) {144+ sq := Tile{W: 6, H: 6, Rects: [][4]int{{0, 0, 6, 6}}}145+ if err := ValidShape(sq.Mask(), 6, 6); err != nil {146+ t.Fatal(err)147+ }148+ if got := len(sq.Lines()); got != 12 {149+ t.Fatalf("6x6 has %d lines, want 12", got)150+ }151+ // L: 16x8 over 8x16152+ l := Tile{W: 16, H: 16, Rects: [][4]int{{0, 0, 16, 8}, {0, 0, 8, 16}}}153+ if err := ValidShape(l.Mask(), 16, 16); err != nil {154+ t.Fatal(err)155+ }156+ lines := l.Lines()157+ // rows: 8 of length 16, 8 of length 8; columns the same158+ if len(lines) != 32 {159+ t.Fatalf("L has %d lines", len(lines))160+ }161+ for _, run := range lines {162+ if len(run) != 8 && len(run) != 16 {163+ t.Fatalf("L line of length %d", len(run))164+ }165+ }166+ // an L with 4-thick arms: 4 parallel lines of length 4 exceed D(4)/2167+ if ValidShape(Tile{W: 8, H: 8, Rects: [][4]int{{0, 0, 8, 4}, {0, 0, 4, 8}}}.Mask(), 8, 8) == nil {168+ t.Fatal("4-thick L accepted")169+ }170+ // odd line: 5-wide171+ if ValidShape(Tile{W: 5, H: 4, Rects: [][4]int{{0, 0, 5, 4}}}.Mask(), 5, 4) == nil {172+ t.Fatal("odd line accepted")173+ }174+ // disconnected175+ if ValidShape(Tile{W: 10, H: 4, Rects: [][4]int{{0, 0, 4, 4}, {6, 0, 4, 4}}}.Mask(), 10, 4) == nil {176+ t.Fatal("disconnected shape accepted")177+ }178+ // short line: a T whose stem is 2 wide179+ if ValidShape(Tile{W: 8, H: 8, Rects: [][4]int{{0, 0, 8, 4}, {3, 4, 2, 4}}}.Mask(), 8, 8) == nil {180+ t.Fatal("2-wide stem accepted")181+ }182+}183+184+func TestGridMatchesPhaseOne(t *testing.T) {185+ L, err := Pack(Params{Seed: "a", G: 27, Kind: "grid", N: 14})186+ if err != nil {187+ t.Fatal(err)188+ }189+ check(t, L)190+ if len(L.Tiles) != 4 || L.Report.Coverage != 1 {191+ t.Fatalf("grid 2x2: %d tiles coverage %.2f", len(L.Tiles), L.Report.Coverage)192+ }193+ // four seams of 14 cells and two diagonal corner touches of one cell194+ seams, corners := 0, 0195+ for _, os := range L.Overlaps {196+ for _, o := range os {197+ switch o.Cells {198+ case 14:199+ seams++200+ case 1:201+ corners++202+ default:203+ t.Fatalf("overlap of %d cells in a 2x2 grid", o.Cells)204+ }205+ }206+ }207+ if seams != 8 || corners != 4 {208+ t.Fatalf("seams %d corners %d (counted from both sides)", seams, corners)209+ }210+ L15, err := Pack(Params{Seed: "a", G: 196, Kind: "grid", N: 14})211+ if err != nil {212+ t.Fatal(err)213+ }214+ check(t, L15)215+ if len(L15.Tiles) != 225 {216+ t.Fatalf("X=15: %d tiles", len(L15.Tiles))217+ }218+}219+220+func TestSquareKindsUnchanged(t *testing.T) {221+ // tile counts the phase-2 packers produced before the shape rewrite222+ // (seed a, G=60 big-first: 34 tiles, measured 2026-08-25)223+ want := map[string]map[int]int{224+ "big-first": {60: 34},225+ }226+ for _, kind := range []string{"bands", "big-first"} {227+ for _, G := range []int{27, 60, 100, 1000} {228+ L, err := Pack(Params{Seed: "a", G: G, Kind: kind})229+ if err != nil {230+ t.Fatal(err)231+ }232+ check(t, L)233+ for _, tl := range L.Tiles {234+ if tl.W != tl.H || len(tl.Rects) != 1 {235+ t.Fatalf("%s: tile %d is not a square", kind, tl.ID)236+ }237+ }238+ if w, ok := want[kind][G]; ok && len(L.Tiles) != w {239+ t.Errorf("%s G=%d: %d tiles, want %d", kind, G, len(L.Tiles), w)240+ }241+ t.Logf("%s G=%d: %d tiles, coverage %.3f, holes %d, %d overlaps", kind, G, len(L.Tiles), L.Report.Coverage, L.Report.Holes, L.Report.Overlaps)242+ }243+ }244+}245+246+// replayAccepts re-applies the scatter placement rule to a layout in247+// placement (id) order, so every overlap is verified against the rule and248+// not against the packer's own bookkeeping.249+func replayAccepts(t *testing.T, L *Layout) {250+ t.Helper()251+ st := &scatterState{G: L.G, own: make([][]int, L.G*L.G)}252+ st.place(L.Tiles[0])253+ for _, tl := range L.Tiles[1:] {254+ ok, why := st.accepts(tl, L.Tiles)255+ if !ok {256+ t.Fatalf("tile %d fails the placement rule: %s", tl.ID, why)257+ }258+ st.place(tl)259+ }260+}261+262+func TestScatter(t *testing.T) {263+ for _, G := range []int{60, 100, 200} {264+ start := time.Now()265+ L, err := Pack(Params{Seed: "a", G: G, Kind: "scatter"})266+ d := time.Since(start)267+ if err != nil {268+ t.Fatal(err)269+ }270+ check(t, L)271+ replayAccepts(t, L)272+ r := L.Report273+ if r.Stopped == "coverage" && r.Coverage < 0.72 {274+ t.Fatalf("G=%d: stopped at coverage but %.3f", G, r.Coverage)275+ }276+ if r.Coverage < 0.6 {277+ t.Errorf("G=%d: coverage %.3f (stopped: %s)", G, r.Coverage, r.Stopped)278+ }279+ // the share is over the drops; fill shapes are chosen by fit280+ drops := L.Tiles[:r.Tiles-r.Filled]281+ share := float64(compoundCount(drops)) / float64(len(drops))282+ if share < 0.2 || share > 0.6 {283+ t.Errorf("G=%d: compound share %.2f, want near 0.4", G, share)284+ }285+ rects, other := 0, 0286+ for _, tl := range L.Tiles {287+ if tl.W != tl.H && len(tl.Rects) == 1 {288+ rects++289+ }290+ if tl.Cells != tl.W*tl.H {291+ other++292+ }293+ }294+ if rects == 0 {295+ t.Errorf("G=%d: no non-square rectangles", G)296+ }297+ maxHole, bigHoles := 0, 0298+ for _, h := range holesOf(L.Owners(), G, L.Tiles) {299+ maxHole = max(maxHole, len(h.cells))300+ if len(h.cells) > r.MaxHole {301+ bigHoles++302+ }303+ }304+ t.Logf("G=%d: %d holes still over max_hole %d, %d of them inside the convex hull", G, bigHoles, r.MaxHole, L.UnfillableHoles(r.MaxHole))305+ t.Logf("G=%d: %d tiles (%d filled) in %s (stopped: %s); coverage %.3f; holes %d (%d cells, mean %.1f, max %d); by cells %v; %d overlaps mean %.1f cells; %d compound, %d non-square rects",306+ G, r.Tiles, r.Filled, d.Round(time.Millisecond), r.Stopped, r.Coverage, r.Holes, r.HoleCells, r.MeanHole, maxHole, r.ByCells, r.Overlaps, r.MeanOverlap, r.Compound, rects)307+ // measured 0.9-1.1 s at G=200 with the uniqueness bound (more308+ // drops are rejected); the bound is loose to keep the test stable309+ if G == 200 && d > 5*time.Second { // smoke bound; ~1-2 s idle, more under load310+ t.Errorf("G=200 took %s", d)311+ }312+ }313+}314+315+func TestScatterDeterministic(t *testing.T) {316+ a, _ := Pack(Params{Seed: "x", G: 120})317+ b, _ := Pack(Params{Seed: "x", G: 120})318+ ja, _ := json.Marshal(a)319+ jb, _ := json.Marshal(b)320+ if string(ja) != string(jb) {321+ t.Fatal("same seed, different layout")322+ }323+ c, _ := Pack(Params{Seed: "y", G: 120})324+ jc, _ := json.Marshal(c)325+ if string(jc) == string(ja) {326+ t.Fatal("different seeds, same layout")327+ }328+ if a.Kind != "scatter" {329+ t.Fatalf("default kind %q", a.Kind)330+ }331+}332+333+// TestScatterCoverage85 reports whether a higher coverage target is334+// reachable within the rejection bound, and the hole statistics.335+func TestScatterCoverage85(t *testing.T) {336+ for _, G := range []int{100, 200} {337+ for _, cov := range []float64{0.72, 0.85} {338+ start := time.Now()339+ L, err := Pack(Params{Seed: "a", G: G, Kind: "scatter", Coverage: cov})340+ d := time.Since(start)341+ if err != nil {342+ t.Fatal(err)343+ }344+ check(t, L)345+ replayAccepts(t, L)346+ maxHole := 0347+ for _, h := range holesOf(L.Owners(), G, L.Tiles) {348+ maxHole = max(maxHole, len(h.cells))349+ }350+ r := L.Report351+ t.Logf("G=%d coverage target %.2f: %d tiles (%d filled) in %s, stopped %s, coverage %.3f, holes %d mean %.1f max %d, %d over max_hole inside the hull",352+ G, cov, r.Tiles, r.Filled, d.Round(time.Millisecond), r.Stopped, r.Coverage, r.Holes, r.MeanHole, maxHole, L.UnfillableHoles(r.MaxHole))353+ }354+ }355+}