• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nhartland / forma / 23412288202

22 Mar 2026 08:54PM UTC coverage: 98.169% (-0.04%) from 98.213%
23412288202

push

github

web-flow
Merge pull request #32 from nhartland/mkdocs

MKdocs rather than Ldoc for documentation generation

9 of 12 new or added lines in 4 files covered. (75.0%)

36 existing lines in 4 files now uncovered.

2091 of 2130 relevant lines covered (98.17%)

8571.78 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.42
/forma/pattern.lua
1
--- A class containing a set (or *pattern*) of cells.
2
---
3
--- The **pattern** class is the central class of `forma`, representing a set of
4
--- points or *cells*. It can be initialized empty or using a prototype (an N×M
5
--- matrix of 1's and 0's). Helper methods for initialization are provided in the
6
--- `primitives` module. Once created, a pattern is modified only via the `insert`
7
--- method—other manipulations return new patterns.
8
---
9
--- Pattern manipulators include methods like `translate`, `enlarge`, `rotate`,
10
--- `hreflect`, and `vreflect`. Other operations, such as computing the `exterior_hull`
11
--- or `interior_hull`, help determine the boundaries of a pattern.
12
--- Coordinates are maintained reliably in the range [-65536, 65536], which can be
13
--- adjusted via the `MAX_COORDINATE` constant.
14
---
15
--- Functions can be invoked either as functions:
16
---
17
---     pattern.method(input_pattern, ... )
18
---
19
--- or as methods:
20
---
21
---     input_pattern:method(... )
22
---
23
--- ```lua
24
--- -- Procedural style:
25
--- local p1 = pattern.new()
26
--- pattern.insert(p1, 1, 1)
27
---
28
--- -- Method chaining:
29
--- local p2 = pattern.new():insert(1, 1)
30
---
31
--- -- Prototype style:
32
--- local p3 = pattern.new({{1,1,1}, {1,0,1}, {1,1,1}})
33
---
34
--- -- Retrieve a random cell and the medoid cell:
35
--- local random_cell = p1:rcell()
36
--- local medoid_cell = p1:medoid()
37
---
38
--- -- Compute the exterior hull using the Moore neighbourhood:
39
--- local outer_hull = p1:exterior_hull(neighbourhood.moore())
40
--- -- or equivalently:
41
--- outer_hull = pattern.exterior_hull(p1, neighbourhood.moore())
42
--- ```
43
---@class forma.pattern
44
---@field max forma.cell maximum bounding box corner
45
---@field min forma.cell minimum bounding box corner
46
---@field offchar string character for inactive cells in tostring
47
---@field onchar string character for active cells in tostring
48
---@field cellkey table list of coordinate keys
49
---@field cellmap table spatial hash of coordinate key to index
50
local pattern = {}
1✔
51

52
local min   = math.min
1✔
53
local max   = math.max
1✔
54
local floor = math.floor
1✔
55

56
local cell          = require('forma.cell')
1✔
57
local neighbourhood = require('forma.neighbourhood')
1✔
58
local rutils        = require('forma.utils.random')
1✔
59
local multipattern  = require('forma.multipattern')
1✔
60

61
-- Pattern indexing.
62
-- Enables the syntax sugar pattern:method.
63
pattern.__index = pattern
1✔
64

65
-- Pattern coordinates (either x or y) must be within ± MAX_COORDINATE.
66
local MAX_COORDINATE  = 65536
1✔
67
local COORDINATE_SPAN = 2 * MAX_COORDINATE + 1
1✔
68

69
--- Generates the cellmap key from coordinates.
70
---@param x number the x-coordinate
71
---@param y number the y-coordinate
72
---@return number key a unique key representing the cell
73
local function coordinates_to_key(x, y)
74
    return (x + MAX_COORDINATE) * COORDINATE_SPAN + (y + MAX_COORDINATE)
1,642,858✔
75
end
76

77
--- Generates the coordinates from the key.
78
---@param key number the spatial hash key
79
---@return number x the x-coordinate
80
---@return number y the y-coordinate
81
local function key_to_coordinates(key)
82
    local yp = (key % COORDINATE_SPAN)
214,519✔
83
    local xp = (key - yp) / COORDINATE_SPAN
214,519✔
84
    return xp - MAX_COORDINATE, yp - MAX_COORDINATE
214,519✔
85
end
86

87
-- @section Basic
88

89
--- Pattern constructor.
90
--- Returns a new pattern. If a prototype is provided (an N×M table of 1's and 0's),
91
--- the corresponding active cells are inserted.
92
---
93
--- ```lua
94
--- local p = pattern.new()
95
--- local p2 = pattern.new({{1,1,1}, {1,0,1}, {1,1,1}})
96
--- ```
97
---@param prototype? table an N×M table of ones and zeros
98
---@return forma.pattern pattern a new pattern according to the prototype
99
function pattern.new(prototype)
1✔
100
    local np = {}
7,319✔
101

102
    np.max = cell.new(-math.huge, -math.huge)
7,319✔
103
    np.min = cell.new(math.huge, math.huge)
7,319✔
104

105
    -- Characters to be used with tostring metamethod.
106
    np.offchar = '0'
7,319✔
107
    np.onchar  = '1'
7,319✔
108

109
    np.cellkey = {} -- Table consisting of a list of coordinate keys.
7,319✔
110
    np.cellmap = {} -- Spatial hash of coordinate key to its index in cellkey.
7,319✔
111

112
    np = setmetatable(np, pattern)
7,319✔
113

114
    if prototype ~= nil then
7,319✔
115
        assert(type(prototype) == 'table',
296✔
116
            'pattern.new requires either no arguments or a N*M matrix as a prototype')
148✔
117
        local N, M = #prototype, nil
148✔
118
        for i = 1, N, 1 do
619✔
119
            local row = prototype[i]
471✔
120
            assert(type(row) == 'table',
942✔
121
                'pattern.new requires either no arguments or a N*M matrix as a prototype')
471✔
122
            if i == 1 then
471✔
123
                M = #row
148✔
124
            else
125
                assert(#row == M,
646✔
126
                    'pattern.new requires a N*M matrix as prototype when called with an argument')
323✔
127
            end
128
            for j = 1, M, 1 do
2,520✔
129
                local icell = row[j]
2,049✔
130
                if icell == 1 then
2,049✔
131
                    np:insert(j - 1, i - 1) -- Patterns start from zero.
1,303✔
132
                else
133
                    assert(icell == 0, 'pattern.new: invalid prototype entry (must be 1 or 0): ' .. icell)
746✔
134
                end
135
            end
136
        end
137
    end
138

139
    return np
7,319✔
140
end
141

142
--- Creates a copy of an existing pattern.
143
---
144
---@param ip forma.pattern input pattern to clone
145
---@return forma.pattern pattern a new pattern that is a duplicate of ip
146
function pattern.clone(ip)
1✔
147
    assert(getmetatable(ip) == pattern, "pattern cloning requires a pattern as the first argument")
1,338✔
148
    local newpat = pattern.new()
1,338✔
149

150
    for x, y in ip:cell_coordinates() do
24,369✔
151
        newpat:insert(x, y)
23,031✔
152
    end
153

154
    newpat.offchar = ip.offchar
1,338✔
155
    newpat.onchar  = ip.onchar
1,338✔
156

157
    return newpat
1,338✔
158
end
159

160
--- Inserts a new cell into the pattern.
161
--- Returns the modified pattern to allow for method chaining.
162
---
163
--- ```lua
164
--- local p = pattern.new():insert(1, 1):insert(2, 2)
165
--- ```
166
---@param ip forma.pattern pattern to modify
167
---@param x integer x-coordinate of the new cell
168
---@param y integer y-coordinate of the new cell
169
---@return forma.pattern ip the updated pattern (for cascading calls)
170
function pattern.insert(ip, x, y)
1✔
171
    assert(floor(x) == x, 'pattern.insert requires an integer for the x coordinate')
189,172✔
172
    assert(floor(y) == y, 'pattern.insert requires an integer for the y coordinate')
189,172✔
173

174
    local key = coordinates_to_key(x, y)
189,172✔
175
    assert(ip.cellmap[key] == nil, "pattern.insert cannot duplicate cells")
189,172✔
176
    local new_index = #ip.cellkey + 1
189,172✔
177
    ip.cellkey[new_index] = key
189,172✔
178
    ip.cellmap[key] = new_index
189,172✔
179

180
    -- Reset pattern extent.
181
    ip.max.x = max(ip.max.x, x)
189,172✔
182
    ip.max.y = max(ip.max.y, y)
189,172✔
183
    ip.min.x = min(ip.min.x, x)
189,172✔
184
    ip.min.y = min(ip.min.y, y)
189,172✔
185

186
    return ip
189,172✔
187
end
188

189
--- Recalculates the bounding box of the pattern from its cells.
190
local function recalculate_bounding_box(ip)
191
    ip.max.x, ip.max.y = -math.huge, -math.huge
925✔
192
    ip.min.x, ip.min.y = math.huge, math.huge
925✔
193
    for x, y in ip:cell_coordinates() do
80,521✔
194
        ip.max.x = max(ip.max.x, x)
79,596✔
195
        ip.max.y = max(ip.max.y, y)
79,596✔
196
        ip.min.x = min(ip.min.x, x)
79,596✔
197
        ip.min.y = min(ip.min.y, y)
79,596✔
198
    end
199
end
200

201
--- Removes a cell from the pattern.
202
--- Returns the modified pattern to allow for method chaining.
203
---
204
--- ```lua
205
--- p:remove(1, 1)
206
--- ```
207
---@param ip forma.pattern pattern to modify
208
---@param x integer x-coordinate of the cell to remove
209
---@param y integer y-coordinate of the cell to remove
210
---@return forma.pattern ip the updated pattern (for cascading calls)
211
function pattern.remove(ip, x, y)
1✔
212
    local key_to_remove = coordinates_to_key(x, y)
2,243✔
213
    local index_to_remove = ip.cellmap[key_to_remove]
2,243✔
214

215
    if index_to_remove then
2,243✔
216
        local last_index = #ip.cellkey
2,242✔
217
        local last_key = ip.cellkey[last_index]
2,242✔
218

219
        -- Swap with last element
220
        ip.cellkey[index_to_remove] = last_key
2,242✔
221
        ip.cellmap[last_key] = index_to_remove
2,242✔
222

223
        -- Pop last element
224
        ip.cellkey[last_index] = nil
2,242✔
225
        ip.cellmap[key_to_remove] = nil
2,242✔
226

227
        -- Recompute bounding box if removed cell was on the boundary
228
        if x == ip.min.x or x == ip.max.x or y == ip.min.y or y == ip.max.y then
2,242✔
229
            recalculate_bounding_box(ip)
925✔
230
        end
231
    end
232

233
    return ip
2,243✔
234
end
235

236
--- Checks if a cell at (x, y) is active in the pattern.
237
---
238
---@param ip forma.pattern pattern to check
239
---@param x integer x-coordinate
240
---@param y integer y-coordinate
241
---@return boolean active true if the cell is active, false otherwise
242
function pattern.has_cell(ip, x, y)
1✔
243
    local key = coordinates_to_key(x, y)
1,451,433✔
244
    return ip.cellmap[key] ~= nil
1,451,433✔
245
end
246

247
--- Filters the pattern using a boolean callback, returning a subpattern.
248
---
249
---@param ip forma.pattern the original pattern
250
---@param fn fun(cell: forma.cell): boolean function that determines if a cell is kept
251
---@return forma.pattern pattern a new pattern containing only the cells that pass the filter
252
function pattern.filter(ip, fn)
1✔
253
    assert(getmetatable(ip) == pattern, "pattern.filter requires a pattern as the first argument")
1✔
254
    assert(type(fn) == 'function', 'pattern.filter requires a function for the second argument')
1✔
255
    local np = pattern.new()
1✔
256
    for icell in ip:cells() do
101✔
257
        if fn(icell) == true then
100✔
258
            np:insert(icell.x, icell.y)
90✔
259
        end
260
    end
261
    return np
1✔
262
end
263

264
--- Returns the number of active cells in the pattern.
265
---
266
---@param ip forma.pattern pattern to measure
267
---@return integer count count of active cells
268
function pattern.size(ip)
1✔
269
    assert(getmetatable(ip) == pattern, "pattern.size requires a pattern as the first argument")
16,149✔
270
    return #ip.cellkey
16,149✔
271
end
272

273
-- @section Utilities
274

275
--- Comparator function to sort patterns by their size (descending).
276
---
277
---@param pa forma.pattern first pattern
278
---@param pb forma.pattern second pattern
279
---@return boolean result true if pa's size is greater than pb's
280
function pattern.size_sort(pa, pb)
1✔
281
    return pa:size() > pb:size()
360✔
282
end
283

284
--- Comparator function to sort patterns by their size (ascending).
285
---
286
---@param pa forma.pattern first pattern
287
---@param pb forma.pattern second pattern
288
---@return boolean result true if pa's size is less than pb's
289
function pattern.inverse_size_sort(pa, pb)
1✔
UNCOV
290
    return pa:size() < pb:size()
×
291
end
292

293
--- Computes how densely the bounding box is filled.
294
--- Returns zero for an empty pattern.
295
---
296
---@param ip forma.pattern input pattern
297
---@return number density the fraction of the bounding box that is occupied
298
function pattern.bounding_box_density(ip)
1✔
299
    assert(getmetatable(ip) == pattern,
6✔
300
        "bounding_box_density: first argument must be a forma.pattern")
3✔
301
    if ip:size() == 0 then return 0 end
3✔
302
    local bb_area = (ip.max.x - ip.min.x + 1) * (ip.max.y - ip.min.y + 1)
3✔
303
    return ip:size() / bb_area
3✔
304
end
305

306
--- Computes the asymmetry of the pattern's bounding box.
307
--- Returns zero in the case of an empty pattern.
308
---
309
---@param ip forma.pattern input pattern
310
---@return number asymmetry the ratio of the bounding box's longest to shortest edge
311
function pattern.bounding_box_asymmetry(ip)
1✔
312
    assert(getmetatable(ip) == pattern,
4✔
313
        "bounding_box_asymmetry: first argument must be a forma.pattern")
2✔
314
    if ip:size() == 0 then return 0 end
2✔
UNCOV
315
    return (
×
316
        (max((ip.max.x - ip.min.x), (ip.max.y - ip.min.y)) + 1)
2✔
317
        / (min((ip.max.x - ip.min.x), (ip.max.y - ip.min.y)) + 1)
2✔
318
    )
2✔
319
end
320

321
--- Counts active neighbors around a specified cell within the pattern.
322
--- Can be invoked with either a cell object or with x and y coordinates.
323
---
324
---@param p forma.pattern a pattern
325
---@param nbh forma.neighbourhood a neighbourhood (e.g., neighbourhood.moore())
326
---@param arg1 forma.cell|integer either a cell or the x-coordinate
327
---@param arg2? integer the y-coordinate if arg1 is not a cell
328
---@return integer count count of active neighbouring cells
329
function pattern.count_neighbors(p, nbh, arg1, arg2)
1✔
330
    assert(getmetatable(p) == pattern,
952✔
331
        "count_neighbors: first argument must be a forma.pattern")
476✔
332
    assert(getmetatable(nbh) == neighbourhood,
952✔
333
        "count_neighbors: second argument must be a neighbourhood")
476✔
334

335
    local x, y
336
    if type(arg1) == 'table' and arg1.x and arg1.y then
476✔
UNCOV
337
        x, y = arg1.x, arg1.y
×
338
    else
339
        x, y = arg1, arg2
476✔
340
    end
341

342
    local count = 0
476✔
343
    for i = 1, #nbh, 1 do
3,384✔
344
        local offset = nbh[i]
2,908✔
345
        local nx, ny = x + offset.x, y + offset.y
2,908✔
346
        if p:has_cell(nx, ny) then
2,908✔
347
            count = count + 1
1,578✔
348
        end
349
    end
350
    return count
476✔
351
end
352

353
--- Returns a list (table) of active cells in the pattern.
354
---
355
---@param ip forma.pattern pattern to list cells from
356
---@return forma.cell[] cells table of cell objects
357
function pattern.cell_list(ip)
1✔
358
    assert(getmetatable(ip) == pattern, "pattern.cell_list requires a pattern as the first argument")
220✔
359
    local newlist = {}
220✔
360
    for icell in ip:cells() do
5,501✔
361
        newlist[#newlist + 1] = icell
5,281✔
362
    end
363
    return newlist
220✔
364
end
365

366
--- Computes the edit distance between two patterns (the total number of differing cells).
367
---
368
---@param a forma.pattern first pattern
369
---@param b forma.pattern second pattern
370
---@return integer distance the edit distance
371
function pattern.edit_distance(a, b)
1✔
372
    assert(getmetatable(a) == pattern, "pattern.edit_distance requires a pattern as the first argument")
5✔
373
    assert(getmetatable(b) == pattern, "pattern.edit_distance requires a pattern as the second argument")
5✔
374
    local common = pattern.intersect(a, b)
5✔
375
    local edit_distance = (a - common):size() + (b - common):size()
5✔
376
    return edit_distance
5✔
377
end
378

379
-- @section Set operations
380

381
--- Returns the union of a set of patterns.
382
---
383
--- ```lua
384
--- local combined = pattern.union(p1, p2, p3)
385
--- ```
386
---@param ... forma.pattern a table of patterns or a list of pattern arguments
387
---@return forma.pattern pattern a new pattern that is the union of the provided patterns
388
function pattern.union(...)
1✔
389
    local patterns = { ... }
35✔
390
    if #patterns == 1 and type(patterns[1]) == 'table' then
35✔
391
        patterns = patterns[1]
10✔
392
    end
393

394
    if #patterns == 0 then return pattern.new() end
35✔
395
    if #patterns == 1 then return patterns[1]:clone() end
35✔
396

397
    local total = pattern.clone(patterns[1])
35✔
398
    for i = 2, #patterns, 1 do
5,130✔
399
        local v = patterns[i]
5,095✔
400
        assert(getmetatable(v) == pattern, "pattern.union requires a pattern as an argument")
5,095✔
401
        for x, y in v:cell_coordinates() do
45,365✔
402
            if total:has_cell(x, y) == false then
40,270✔
403
                total:insert(x, y)
40,251✔
404
            end
405
        end
406
    end
407
    return total
35✔
408
end
409

410
--- Returns the intersection of multiple patterns (cells common to all).
411
---
412
--- ```lua
413
--- local common = pattern.intersect(p1, p2)
414
--- ```
415
---@param ... forma.pattern two or more patterns to intersect
416
---@return forma.pattern pattern a new pattern of cells that exist in every input pattern
417
function pattern.intersect(...)
1✔
418
    local patterns = { ... }
360✔
419
    assert(#patterns > 1, "pattern.intersect requires at least two patterns as arguments")
360✔
420
    table.sort(patterns, pattern.size_sort)
360✔
421
    local domain = patterns[#patterns]
360✔
422
    local inter  = pattern.new()
360✔
423
    for x, y in domain:cell_coordinates() do
8,716✔
424
        local foundCell = true
8,356✔
425
        for i = #patterns - 1, 1, -1 do
15,029✔
426
            local tpattern = patterns[i]
8,356✔
427
            assert(getmetatable(tpattern) == pattern,
16,712✔
428
                "pattern.intersect requires a pattern as an argument")
8,356✔
429
            if not tpattern:has_cell(x, y) then
8,356✔
430
                foundCell = false
1,683✔
431
                break
1,683✔
432
            end
433
        end
434
        if foundCell == true then
8,356✔
435
            inter:insert(x, y)
6,673✔
436
        end
437
    end
438
    return inter
360✔
439
end
440

441
--- Returns the symmetric difference (XOR) of two patterns.
442
--- Cells are included if they exist in either pattern but not in both.
443
---
444
---@param a forma.pattern first pattern
445
---@param b forma.pattern second pattern
446
---@return forma.pattern pattern a new pattern representing the symmetric difference
447
function pattern.xor(a, b)
1✔
448
    assert(getmetatable(a) == pattern, "pattern.xor requires a pattern as the first argument")
3✔
449
    assert(getmetatable(b) == pattern, "pattern.xor requires a pattern as the second argument")
3✔
450
    return (a + b) - (a * b)
3✔
451
end
452

453
-- @section Iterators
454

455
--- Iterator over active cells in the pattern.
456
---
457
--- ```lua
458
--- for cell in p:cells() do
459
---     print(cell.x, cell.y)
460
--- end
461
--- ```
462
---@param ip forma.pattern pattern to iterate over
463
---@return fun(): forma.cell? iterator that returns each active cell as a cell object
464
function pattern.cells(ip)
1✔
465
    assert(getmetatable(ip) == pattern, "pattern.cells requires a pattern as the first argument")
1,336✔
466
    local icell, ncells = 0, ip:size()
1,336✔
467
    return function()
468
        icell = icell + 1
14,558✔
469
        if icell <= ncells then
14,558✔
470
            local ikey = ip.cellkey[icell]
13,294✔
471
            local x, y = key_to_coordinates(ikey)
13,294✔
472
            return cell.new(x, y)
13,294✔
473
        end
474
    end
475
end
476

477
--- Iterator over active cell coordinates (x, y) in the pattern.
478
---
479
--- ```lua
480
--- for x, y in p:cell_coordinates() do
481
---     print(x, y)
482
--- end
483
--- ```
484
---@param ip forma.pattern pattern to iterate over
485
---@return fun(): integer?, integer? iterator that returns the x and y coordinates of each active cell
486
function pattern.cell_coordinates(ip)
1✔
487
    assert(getmetatable(ip) == pattern, "pattern.cell_coordinates requires a pattern as the first argument")
10,236✔
488
    local icell, ncells = 0, ip:size()
10,236✔
489
    return function()
490
        icell = icell + 1
209,786✔
491
        if icell <= ncells then
209,786✔
492
            local ikey = ip.cellkey[icell]
199,550✔
493
            return key_to_coordinates(ikey)
199,550✔
494
        end
495
    end
496
end
497

498
--- Returns an iterator over active cells in randomized order.
499
---
500
--- ```lua
501
--- for cell in pattern.shuffled_cells(p) do
502
---     print(cell.x, cell.y)
503
--- end
504
--- ```
505
---@param ip forma.pattern pattern to iterate over
506
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
507
---@return fun(): forma.cell? iterator that yields each active cell in a random order
508
function pattern.shuffled_cells(ip, rng)
1✔
509
    assert(getmetatable(ip) == pattern,
206✔
510
        "pattern.shuffled_cells requires a pattern as the first argument")
103✔
511
    if rng == nil then rng = math.random end
103✔
512
    local icell, ncells = 0, ip:size()
103✔
513
    local cellkeys = ip.cellkey
103✔
514
    local skeys = rutils.shuffled_copy(cellkeys, rng)
103✔
515
    return function()
516
        icell = icell + 1
504✔
517
        if icell <= ncells then
504✔
518
            local ikey = skeys[icell]
501✔
519
            local x, y = key_to_coordinates(ikey)
501✔
520
            return cell.new(x, y)
501✔
521
        end
522
    end
523
end
524

525
--- Returns an iterator over active cell coordinates in randomized order.
526
---
527
--- ```lua
528
--- for x, y in pattern.shuffled_coordinates(p) do
529
---     print(x, y)
530
--- end
531
--- ```
532
---@param ip forma.pattern pattern to iterate over
533
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
534
---@return fun(): integer?, integer? iterator that yields x and y coordinates in random order
535
function pattern.shuffled_coordinates(ip, rng)
1✔
536
    assert(getmetatable(ip) == pattern,
46✔
537
        "pattern.shuffled_coordinates requires a pattern as the first argument")
23✔
538
    if rng == nil then rng = math.random end
23✔
539
    local icell, ncells = 0, ip:size()
23✔
540
    local cellkeys = ip.cellkey
23✔
541
    local skeys = rutils.shuffled_copy(cellkeys, rng)
23✔
542
    return function()
543
        icell = icell + 1
658✔
544
        if icell <= ncells then
658✔
545
            local ikey = skeys[icell]
656✔
546
            return key_to_coordinates(ikey)
656✔
547
        end
548
    end
549
end
550

551
-- @section Metamethods
552

553
--- Renders the pattern as a string.
554
--- Active cells are shown with pattern.onchar and inactive cells with pattern.offchar.
555
---
556
--- ```lua
557
--- print(p)
558
--- ```
559
---@param ip forma.pattern pattern to render
560
---@return string str string representation of the pattern
561
function pattern.__tostring(ip)
1✔
562
    local str = '- pattern origin: ' .. tostring(ip.min) .. '\n'
1✔
563
    for y = ip.min.y, ip.max.y, 1 do
6✔
564
        for x = ip.min.x, ip.max.x, 1 do
30✔
565
            local char = ip:has_cell(x, y) and ip.onchar or ip.offchar
25✔
566
            str = str .. char
25✔
567
        end
568
        str = str .. '\n'
5✔
569
    end
570
    return str
1✔
571
end
572

573
--- Adds two patterns using the '+' operator (i.e. returns their union).
574
---
575
--- ```lua
576
--- local combined = p1 + p2
577
--- ```
578
---@param a forma.pattern first pattern
579
---@param b forma.pattern second pattern
580
---@return forma.pattern pattern a new pattern representing the union of a and b
581
function pattern.__add(a, b)
1✔
582
    assert(getmetatable(a) == pattern, "pattern addition requires a pattern as the first argument")
23✔
583
    assert(getmetatable(b) == pattern, "pattern addition requires a pattern as the second argument")
23✔
584
    return pattern.union(a, b)
23✔
585
end
586

587
--- Subtracts one pattern from another using the '-' operator.
588
--- Returns a new pattern with cells in a that are not in b.
589
---
590
--- ```lua
591
--- local diff = p1 - p2
592
--- ```
593
---@param a forma.pattern base pattern
594
---@param b forma.pattern pattern to subtract from a
595
---@return forma.pattern pattern a new pattern with the difference
596
function pattern.__sub(a, b)
1✔
597
    assert(getmetatable(a) == pattern, "pattern subtraction requires a pattern as the first argument")
39✔
598
    assert(getmetatable(b) == pattern, "pattern subtraction requires a pattern as the second argument")
39✔
599
    local c = pattern.new()
39✔
600
    for x, y in a:cell_coordinates() do
12,351✔
601
        if b:has_cell(x, y) == false then
12,312✔
602
            c:insert(x, y)
1,750✔
603
        end
604
    end
605
    return c
39✔
606
end
607

608
--- Computes the intersection of two patterns using the '*' operator.
609
---
610
--- ```lua
611
--- local common = p1 * p2
612
--- ```
613
---@param a forma.pattern first pattern
614
---@param b forma.pattern second pattern
615
---@return forma.pattern pattern a new pattern containing only the cells common to both
616
function pattern.__mul(a, b)
1✔
617
    assert(getmetatable(a) == pattern, "pattern multiplication requires a pattern as the first argument")
4✔
618
    assert(getmetatable(b) == pattern, "pattern multiplication requires a pattern as the second argument")
4✔
619
    return pattern.intersect(a, b)
4✔
620
end
621

622
--- Computes the symmetric difference (XOR) of two patterns using the '^' operator.
623
---
624
--- ```lua
625
--- local xor_pattern = p1 ^ p2
626
--- ```
627
---@param a forma.pattern first pattern
628
---@param b forma.pattern second pattern
629
---@return forma.pattern pattern a new pattern with cells present in either a or b, but not both
630
function pattern.__pow(a, b)
1✔
631
    assert(getmetatable(a) == pattern, "pattern exponent (XOR) requires a pattern as the first argument")
1✔
632
    assert(getmetatable(b) == pattern, "pattern exponent (XOR) requires a pattern as the second argument")
1✔
633
    return pattern.xor(a, b)
1✔
634
end
635

636
--- Tests whether two patterns are identical.
637
---
638
--- ```lua
639
--- if p1 == p2 then
640
---     -- patterns are identical
641
--- end
642
--- ```
643
---@param a forma.pattern first pattern
644
---@param b forma.pattern second pattern
645
---@return boolean equal true if the patterns are equal, false otherwise
646
function pattern.__eq(a, b)
1✔
647
    assert(getmetatable(a) == pattern, "pattern equality test requires a pattern as the first argument")
371✔
648
    assert(getmetatable(b) == pattern, "pattern equality test requires a pattern as the second argument")
371✔
649
    if a:size() ~= b:size() then return false end
371✔
650
    if a.min ~= b.min then return false end
366✔
651
    if a.max ~= b.max then return false end
358✔
652
    for i = 1, #a.cellkey do
46,556✔
653
        if b.cellmap[a.cellkey[i]] == nil then
46,199✔
654
            return false
1✔
655
        end
656
    end
657
    return true
357✔
658
end
659

660
-- @section Cell accessors
661

662
--- Computes the centroid (arithmetic mean) of all cells in the pattern.
663
--- The result is rounded to the nearest integer coordinate.
664
---
665
--- ```lua
666
--- local center = p:centroid()
667
--- ```
668
---@param ip forma.pattern pattern to process
669
---@return forma.cell centroid a cell representing the centroid (which may not be active)
670
function pattern.centroid(ip)
1✔
671
    assert(getmetatable(ip) == pattern, "pattern.centroid requires a pattern as the first argument")
147✔
672
    assert(ip:size() > 0, 'pattern.centroid requires a filled pattern!')
147✔
673
    local sumx, sumy = 0, 0
147✔
674
    for x, y in ip:cell_coordinates() do
5,622✔
675
        sumx = sumx + x
5,475✔
676
        sumy = sumy + y
5,475✔
677
    end
678
    local n = ip:size()
147✔
679
    local intx = floor(sumx / n + 0.5)
147✔
680
    local inty = floor(sumy / n + 0.5)
147✔
681
    return cell.new(intx, inty)
147✔
682
end
683

684
--- Computes the medoid cell of the pattern.
685
--- The medoid minimizes the total distance to all other cells (using Euclidean distance by default).
686
---
687
--- ```lua
688
--- local medoid = p:medoid()
689
--- ```
690
---@param ip forma.pattern pattern to process
691
---@param measure? fun(a: forma.cell, b: forma.cell): number distance function (default: Euclidean)
692
---@return forma.cell medoid the medoid cell of the pattern
693
function pattern.medoid(ip, measure)
1✔
694
    assert(getmetatable(ip) == pattern, "pattern.medoid requires a pattern as the first argument")
117✔
695
    assert(ip:size() > 0, 'pattern.medoid requires a filled pattern!')
117✔
696
    measure = measure or cell.euclidean2
117✔
697
    local ncells = ip:size()
117✔
698
    local cell_list = ip:cell_list()
117✔
699
    local distance = {}
117✔
700
    for _ = 1, ncells, 1 do distance[#distance + 1] = 0 end
208✔
701
    local minimal_distance = math.huge
117✔
702
    local minimal_index = -1
117✔
703
    for i = 1, ncells, 1 do
325✔
704
        for j = i, ncells, 1 do
1,182✔
705
            local ij_distance = measure(cell_list[i], cell_list[j])
974✔
706
            distance[i] = distance[i] + ij_distance
974✔
707
            distance[j] = distance[j] + ij_distance
974✔
708
        end
709
        if distance[i] < minimal_distance then
208✔
710
            minimal_index = i
138✔
711
            minimal_distance = distance[i]
138✔
712
        end
713
    end
714
    return cell_list[minimal_index]
117✔
715
end
716

717
--- Returns a random cell from the pattern.
718
---
719
--- ```lua
720
--- local random_cell = p:rcell()
721
--- ```
722
---@param ip forma.pattern pattern to sample from
723
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
724
---@return forma.cell cell a random cell from the pattern
725
function pattern.rcell(ip, rng)
1✔
726
    assert(getmetatable(ip) == pattern, "pattern.rcell requires a pattern as the first argument")
508✔
727
    assert(ip:size() > 0, 'pattern.rcell requires a filled pattern!')
508✔
728
    if rng == nil then rng = math.random end
508✔
729
    local icell = rng(#ip.cellkey)
508✔
730
    local ikey = ip.cellkey[icell]
508✔
731
    local x, y = key_to_coordinates(ikey)
508✔
732
    return cell.new(x, y)
508✔
733
end
734

735
-- @section Transformations
736

737
--- Returns a new pattern translated by a vector (sx, sy).
738
---
739
--- ```lua
740
--- local p_translated = p:translate(2, 3)
741
--- ```
742
---@param ip forma.pattern pattern to translate
743
---@param sx integer translation along the x-axis
744
---@param sy integer translation along the y-axis
745
---@return forma.pattern pattern a new pattern shifted by (sx, sy)
746
function pattern.translate(ip, sx, sy)
1✔
747
    assert(getmetatable(ip) == pattern, "pattern.translate requires a pattern as the first argument")
1,304✔
748
    assert(floor(sx) == sx, 'pattern.translate requires an integer for the x coordinate')
1,304✔
749
    assert(floor(sy) == sy, 'pattern.translate requires an integer for the y coordinate')
1,304✔
750
    local sp = pattern.new()
1,304✔
751
    for tx, ty in ip:cell_coordinates() do
13,619✔
752
        local nx = tx + sx
12,315✔
753
        local ny = ty + sy
12,315✔
754
        sp:insert(nx, ny)
12,315✔
755
    end
756
    return sp
1,304✔
757
end
758

759
--- Normalizes the pattern by translating it so that its minimum coordinate is (0,0).
760
---
761
--- ```lua
762
--- local p_norm = p:normalise()
763
--- ```
764
---@param ip forma.pattern pattern to normalize
765
---@return forma.pattern pattern a new normalized pattern
766
function pattern.normalise(ip)
1✔
767
    assert(getmetatable(ip) == pattern, "pattern.normalise requires a pattern as the first argument")
4✔
768
    return ip:translate(-ip.min.x, -ip.min.y)
4✔
769
end
770

771
--- Returns an enlarged version of the pattern.
772
--- Each active cell is replaced by an f×f block.
773
---
774
--- ```lua
775
--- local p_big = p:enlarge(2)
776
--- ```
777
---@param ip forma.pattern pattern to enlarge
778
---@param f number enlargement factor
779
---@return forma.pattern pattern a new enlarged pattern
780
function pattern.enlarge(ip, f)
1✔
781
    assert(getmetatable(ip) == pattern, "pattern.enlarge requires a pattern as the first argument")
4✔
782
    assert(type(f) == 'number', 'pattern.enlarge requires a number as the enlargement factor')
4✔
783
    local ep = pattern.new()
4✔
784
    for icell in ip:cells() do
34✔
785
        local sv = cell.new(f * icell.x, f * icell.y)
30✔
786
        for i = 0, f - 1, 1 do
90✔
787
            for j = 0, f - 1, 1 do
180✔
788
                ep:insert(sv.x + i, sv.y + j)
120✔
789
            end
790
        end
791
    end
792
    return ep
4✔
793
end
794

795
--- Returns a new pattern rotated 90° clockwise about the origin.
796
---
797
--- ```lua
798
--- local p_rotated = p:rotate()
799
--- ```
800
---@param ip forma.pattern pattern to rotate
801
---@return forma.pattern pattern a rotated pattern
802
function pattern.rotate(ip)
1✔
803
    assert(getmetatable(ip) == pattern, "pattern.rotate requires a pattern as the first argument")
6✔
804
    local np = pattern.new()
6✔
805
    for x, y in ip:cell_coordinates() do
46✔
806
        np:insert(y, -x)
40✔
807
    end
808
    return np
6✔
809
end
810

811
--- Returns a new pattern that is a vertical reflection of the original.
812
---
813
--- ```lua
814
--- local p_vreflected = p:vreflect()
815
--- ```
816
---@param ip forma.pattern pattern to reflect vertically
817
---@return forma.pattern pattern a vertically reflected pattern
818
function pattern.vreflect(ip)
1✔
819
    assert(getmetatable(ip) == pattern, "pattern.vreflect requires a pattern as the first argument")
2✔
820
    local np = pattern.new()
2✔
821
    for vx, vy in ip:cell_coordinates() do
6✔
822
        local new_y = ip.min.y + ip.max.y - vy
4✔
823
        np:insert(vx, new_y)
4✔
824
    end
825
    return np
2✔
826
end
827

828
--- Returns a new pattern that is a horizontal reflection of the original.
829
---
830
--- ```lua
831
--- local p_hreflected = p:hreflect()
832
--- ```
833
---@param ip forma.pattern pattern to reflect horizontally
834
---@return forma.pattern pattern a horizontally reflected pattern
835
function pattern.hreflect(ip)
1✔
836
    assert(getmetatable(ip) == pattern, "pattern.hreflect requires a pattern as the first argument")
2✔
837
    local np = pattern.new()
2✔
838
    for vx, vy in ip:cell_coordinates() do
6✔
839
        local new_x = ip.min.x + ip.max.x - vx
4✔
840
        np:insert(new_x, vy)
4✔
841
    end
842
    return np
2✔
843
end
844

845
-- @section Sampling
846

847
--- Returns a random subpattern containing a fixed number of cells.
848
---
849
--- ```lua
850
--- local sample = p:sample(10)
851
--- ```
852
---@param ip forma.pattern pattern (domain) to sample from
853
---@param ncells integer number of cells to sample
854
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
855
---@return forma.pattern pattern a new pattern with ncells randomly selected cells
856
function pattern.sample(ip, ncells, rng)
1✔
857
    assert(getmetatable(ip) == pattern, "pattern.sample requires a pattern as the first argument")
18✔
858
    assert(type(ncells) == 'number', "pattern.sample requires an integer number of cells as the second argument")
18✔
859
    assert(math.floor(ncells) == ncells, "pattern.sample requires an integer number of cells as the second argument")
18✔
860
    assert(ncells > 0, "pattern.sample requires at least one sample to be requested")
18✔
861
    assert(ncells <= ip:size(), "pattern.sample requires a domain larger than the number of requested samples")
18✔
862
    if rng == nil then rng = math.random end
18✔
863
    local p = pattern.new()
18✔
864
    local next_coords = ip:shuffled_coordinates(rng)
18✔
865
    for _ = 1, ncells, 1 do
228✔
866
        local x, y = next_coords()
210✔
867
        assert(x and y)
210✔
868
        p:insert(x, y)
210✔
869
    end
870
    return p
18✔
871
end
872

873
--- Returns a Poisson-disc sampled subpattern.
874
--- Ensures that no two sampled cells are closer than the given radius.
875
---
876
--- ```lua
877
--- local poisson_sample = p:sample_poisson(cell.euclidean, 5)
878
--- ```
879
---@param ip forma.pattern pattern (domain) to sample from
880
---@param distance fun(a: forma.cell, b: forma.cell): number distance function (e.g., cell.euclidean)
881
---@param radius number minimum separation
882
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
883
---@return forma.pattern pattern a new pattern sampled with Poisson-disc criteria
884
function pattern.sample_poisson(ip, distance, radius, rng)
1✔
885
    assert(getmetatable(ip) == pattern, "pattern.sample_poisson requires a pattern as the first argument")
1✔
886
    assert(type(distance) == 'function', "pattern.sample_poisson requires a distance measure as an argument")
1✔
887
    assert(type(radius) == "number", "pattern.sample_poisson requires a number as the target radius")
1✔
888
    if rng == nil then rng = math.random end
1✔
889
    local sample = pattern.new()
1✔
890
    local domain = ip:clone()
1✔
891
    while domain:size() > 0 do
10✔
892
        local dart = domain:rcell(rng)
9✔
893
        sample:insert(dart.x, dart.y)
9✔
894
        local to_remove = {}
9✔
895
        for cell_to_check in domain:cells() do
373✔
896
            if distance(cell_to_check, dart) < radius then
364✔
897
                table.insert(to_remove, cell_to_check)
100✔
898
            end
899
        end
900
        for _, cell_to_remove in ipairs(to_remove) do
109✔
901
            domain:remove(cell_to_remove.x, cell_to_remove.y)
100✔
902
        end
903
    end
904
    return sample
1✔
905
end
906

907
--- Returns an approximate Poisson-disc sample using Mitchell's best candidate algorithm.
908
---
909
--- ```lua
910
--- local mitchell_sample = p:sample_mitchell(cell.euclidean, 10, 5)
911
--- ```
912
---@param ip forma.pattern the input pattern to sample from
913
---@param distance fun(a: forma.cell, b: forma.cell): number distance function (e.g., cell.euclidean)
914
---@param n integer number of samples
915
---@param k integer number of candidate attempts per iteration
916
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
917
---@return forma.pattern pattern a new pattern with n samples chosen via the algorithm
918
function pattern.sample_mitchell(ip, distance, n, k, rng)
1✔
919
    assert(getmetatable(ip) == pattern,
2✔
920
        "pattern.sample_mitchell requires a pattern as the first argument")
1✔
921
    assert(ip:size() >= n,
2✔
922
        "pattern.sample_mitchell requires a pattern with at least as many points as in the requested sample")
1✔
923
    assert(type(distance) == 'function', "pattern.sample_mitchell requires a distance measure as an argument")
1✔
924
    assert(type(n) == "number", "pattern.sample_mitchell requires a target number of samples")
1✔
925
    assert(type(k) == "number", "pattern.sample_mitchell requires a target number of candidate tries")
1✔
926
    if rng == nil then rng = math.random end
1✔
927
    local seed = ip:rcell(rng)
1✔
928
    local sample = pattern.new():insert(seed.x, seed.y)
1✔
929
    for _ = 2, n, 1 do
10✔
930
        local min_distance = 0
9✔
931
        local min_sample = nil
9✔
932
        for _ = 1, k, 1 do
99✔
933
            local jcell = ip:rcell(rng)
90✔
934
            while sample:has_cell(jcell.x, jcell.y) do
95✔
935
                jcell = ip:rcell(rng)
5✔
936
            end
937
            local jdistance = math.huge
90✔
938
            for vcell in sample:cells() do
540✔
939
                jdistance = math.min(jdistance, distance(jcell, vcell))
450✔
940
            end
941
            if jdistance > min_distance then
90✔
942
                min_sample = jcell
17✔
943
                min_distance = jdistance
17✔
944
            end
945
        end
946
        if min_sample then
9✔
947
            sample:insert(min_sample.x, min_sample.y)
9✔
948
        end
949
    end
950
    return sample
1✔
951
end
952

953
--- Returns the contiguous subpattern (connected component) starting from a given location.
954
local function floodfill(x, y, nbh, domain, retpat)
955
    local q = { x, y }
727✔
956
    local head = 1
727✔
957
    local tail = 2
727✔
958
    retpat:insert(x, y)
727✔
959

960
    while head < tail do
21,281✔
961
        local cx, cy = q[head], q[head + 1]
20,554✔
962
        head = head + 2
20,554✔
963

964
        for i = 1, #nbh do
175,386✔
965
            local nx = nbh[i].x + cx
154,832✔
966
            local ny = nbh[i].y + cy
154,832✔
967
            if domain:has_cell(nx, ny) and not retpat:has_cell(nx, ny) then
154,832✔
968
                retpat:insert(nx, ny)
19,827✔
969
                q[tail + 1] = nx
19,827✔
970
                q[tail + 2] = ny
19,827✔
971
                tail = tail + 2
19,827✔
972
            end
973
        end
974
    end
975
end
976

977
-- @section Subpattern finding
978

979
--- Returns the contiguous subpattern (connected component) starting from a given cell.
980
---
981
--- ```lua
982
--- local component = p:floodfill(cell.new(2, 3))
983
--- ```
984
---@param ip forma.pattern pattern upon which the flood fill is to be performed
985
---@param icell forma.cell a cell specifying the origin of the flood fill
986
---@param nbh? forma.neighbourhood neighbourhood to use (default: neighbourhood.moore())
987
---@return forma.pattern pattern a new pattern containing the connected component
988
function pattern.floodfill(ip, icell, nbh)
1✔
989
    assert(getmetatable(ip) == pattern, "pattern.floodfill requires a pattern as the first argument")
727✔
990
    assert(icell, "pattern.floodfill requires a cell as the second argument")
727✔
991
    if nbh == nil then nbh = neighbourhood.moore() end
727✔
992
    local retpat = pattern.new()
727✔
993
    if ip:has_cell(icell.x, icell.y) then
727✔
994
        floodfill(icell.x, icell.y, nbh, ip, retpat)
727✔
995
    end
996
    return retpat
727✔
997
end
998

999
--- Finds the largest contiguous rectangular subpattern within the pattern.
1000
---
1001
--- ```lua
1002
--- local rect = p:max_rectangle()
1003
--- ```
1004
---@param ip forma.pattern pattern to analyze
1005
---@param alpha? number 'squareness' parameter. 0 for max rectangle, 1 for max square
1006
---@return forma.pattern pattern a subpattern representing the maximal rectangle
1007
function pattern.max_rectangle(ip, alpha)
1✔
1008
    assert(getmetatable(ip) == pattern, "pattern.max_rectangle requires a pattern as an argument")
2✔
1009
    local primitives = require('forma.primitives')
2✔
1010
    local bsp = require('forma.utils.bsp')
2✔
1011
    local min_rect, max_rect = bsp.max_rectangle_coordinates(ip, alpha)
2✔
1012
    local size = max_rect - min_rect + cell.new(1, 1)
2✔
1013
    return primitives.square(size.x, size.y):translate(min_rect.x, min_rect.y)
2✔
1014
end
1015

1016
--- Computes the convex hull of the pattern.
1017
--- The hull points are connected using line rasterization.
1018
---
1019
--- ```lua
1020
--- local hull = p:convex_hull()
1021
--- ```
1022
---@param ip forma.pattern pattern to process
1023
---@return forma.pattern pattern a new pattern representing the convex hull
1024
function pattern.convex_hull(ip)
1✔
1025
    assert(getmetatable(ip) == pattern, "pattern.convex_hull requires a pattern as the first argument")
1✔
1026
    assert(ip:size() > 0, "pattern.convex_hull: input pattern must have at least one cell")
1✔
1027
    local convex_hull = require('forma.utils.convex_hull')
1✔
1028
    local primitives = require('forma.primitives')
1✔
1029
    local hull_points = convex_hull.points(ip)
1✔
1030
    local chull = pattern.new()
1✔
1031
    local function add_line(p1, p2)
1032
        local line = primitives.line(p1, p2)
4✔
1033
        for x, y in line:cell_coordinates() do
24✔
1034
            if not chull:has_cell(x, y) then
20✔
1035
                chull:insert(x, y)
16✔
1036
            end
1037
        end
1038
    end
1039
    for i = 1, #hull_points - 1, 1 do
4✔
1040
        add_line(hull_points[i], hull_points[i + 1])
3✔
1041
    end
1042
    add_line(hull_points[#hull_points], hull_points[1])
1✔
1043
    return chull
1✔
1044
end
1045

1046
--- Returns a thinned (skeletonized) version of the pattern.
1047
--- Iteratively removes border cells whose Moore neighbours remain a single
1048
--- connected component under `nbh`.
1049
---
1050
--- ```lua
1051
--- local thin_p = p:thin()
1052
--- local thin_4 = p:thin(neighbourhood.von_neumann())
1053
--- ```
1054
---@param ip forma.pattern pattern to thin
1055
---@param nbh? forma.neighbourhood neighbourhood for connectivity (default: neighbourhood.moore())
1056
---@return forma.pattern pattern a new, thinned pattern
1057
function pattern.thin(ip, nbh)
1✔
1058
    assert(getmetatable(ip) == pattern,
8✔
1059
        "pattern.thin requires a pattern as the first argument")
4✔
1060
    nbh = nbh or neighbourhood.moore()
4✔
1061
    assert(getmetatable(nbh) == neighbourhood,
8✔
1062
        "pattern.thin requires a neighbourhood as the second argument")
4✔
1063

1064
    -- Moore neighbourhood used for border detection and local pruning
1065
    local moore = neighbourhood.moore()
4✔
1066
    -- Each iteration does one sub-pass per cardinal direction, only removing
1067
    -- cells exposed on that side.
1068
    local von_neumann = neighbourhood.von_neumann()
4✔
1069

1070
    local p = ip:clone()
4✔
1071
    local function can_delete(x, y, border_cell)
1072
        -- Cell must be exposed on the border side
1073
        if p:has_cell(x + border_cell.x, y + border_cell.y) then return false end
3,769✔
1074
        -- Don't delete endpoints
1075
        if pattern.count_neighbors(p, nbh, x, y) < 2 then return false end
476✔
1076
        -- All Moore neighbours must form a single nbh-connected component
1077
        local nbrs = pattern.new()
445✔
1078
        local seed = nil
445✔
1079
        for i = 1, #moore do
4,005✔
1080
            if p:has_cell(x + moore[i].x, y + moore[i].y) then
3,560✔
1081
                nbrs:insert(moore[i].x, moore[i].y)
1,889✔
1082
                seed = seed or cell.new(moore[i].x, moore[i].y)
1,889✔
1083
            end
1084
        end
1085
        -- If there are no Moore neighbours, we cannot safely delete this cell
1086
        if not seed then
445✔
NEW
1087
            return false
×
1088
        end
1089
        return pattern.floodfill(nbrs, seed, nbh):size() == nbrs:size()
445✔
1090
    end
1091
    local changed = true
4✔
1092
    while changed do
19✔
1093
        changed = false
15✔
1094
        for _, border_cell in ipairs(von_neumann) do
75✔
1095
            local to_remove = {}
60✔
1096
            for x, y in p:cell_coordinates() do
3,829✔
1097
                if can_delete(x, y, border_cell) then
3,769✔
1098
                    to_remove[#to_remove + 1] = { x, y }
385✔
1099
                end
1100
            end
1101
            for _, xy in ipairs(to_remove) do
445✔
1102
                p:remove(xy[1], xy[2])
385✔
1103
                changed = true
385✔
1104
            end
1105
        end
1106
    end
1107

1108
    return p
4✔
1109
end
1110

1111
-- @section Morphological operations
1112

1113
--- Returns the erosion of the pattern.
1114
--- A cell is retained only if all of its neighbours (as defined by nbh) are active.
1115
---
1116
--- ```lua
1117
--- local eroded = p:erode(neighbourhood.moore())
1118
--- ```
1119
---@param ip forma.pattern pattern to erode
1120
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1121
---@return forma.pattern pattern a new, eroded pattern
1122
function pattern.erode(ip, nbh)
1✔
1123
    nbh = nbh or neighbourhood.moore()
8✔
1124
    assert(getmetatable(ip) == pattern, "pattern.erode requires a pattern as the first argument")
8✔
1125
    assert(getmetatable(nbh) == neighbourhood, "pattern.erode requires a neighbourhood as the second argument")
8✔
1126
    local result = pattern.new()
8✔
1127
    for x, y in ip:cell_coordinates() do
105✔
1128
        local keep = true
97✔
1129
        for j = 1, #nbh, 1 do
300✔
1130
            local offset = nbh[j]
287✔
1131
            local nx = x + offset.x
287✔
1132
            local ny = y + offset.y
287✔
1133
            if not ip:has_cell(nx, ny) then
287✔
1134
                keep = false
84✔
1135
                break
84✔
1136
            end
1137
        end
1138
        if keep then
97✔
1139
            result:insert(x, y)
13✔
1140
        end
1141
    end
1142
    return result
8✔
1143
end
1144

1145
--- Returns the dilation of the pattern.
1146
--- Each active cell contributes its neighbours (as defined by nbh) to the result.
1147
---
1148
--- ```lua
1149
--- local dilated = p:dilate(neighbourhood.moore())
1150
--- ```
1151
---@param ip forma.pattern pattern to dilate
1152
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1153
---@return forma.pattern pattern a new, dilated pattern
1154
function pattern.dilate(ip, nbh)
1✔
1155
    nbh = nbh or neighbourhood.moore()
8✔
1156
    assert(getmetatable(ip) == pattern, "pattern.dilate requires a pattern as the first argument")
8✔
1157
    assert(getmetatable(nbh) == neighbourhood, "pattern.dilate requires a neighbourhood as the second argument")
8✔
1158
    local np = pattern.clone(ip)
8✔
1159
    for x, y in ip:cell_coordinates() do
31✔
1160
        for j = 1, #nbh, 1 do
199✔
1161
            local offset = nbh[j]
176✔
1162
            local nx = x + offset.x
176✔
1163
            local ny = y + offset.y
176✔
1164
            if not np:has_cell(nx, ny) then
176✔
1165
                np:insert(nx, ny)
73✔
1166
            end
1167
        end
1168
    end
1169
    return np
8✔
1170
end
1171

1172
--- Returns the morphological gradient of the pattern.
1173
--- Computes the difference between the dilation and erosion.
1174
---
1175
--- ```lua
1176
--- local grad = p:gradient(neighbourhood.moore())
1177
--- ```
1178
---@param ip forma.pattern pattern to process
1179
---@param nbh? forma.neighbourhood neighbourhood for dilation/erosion (default: neighbourhood.moore())
1180
---@return forma.pattern pattern a new pattern representing the gradient
1181
function pattern.gradient(ip, nbh)
1✔
1182
    nbh = nbh or neighbourhood.moore()
2✔
1183
    assert(getmetatable(ip) == pattern, "pattern.gradient requires a pattern as the first argument")
2✔
1184
    assert(getmetatable(nbh) == neighbourhood, "pattern.gradient requires a neighbourhood as the second argument")
2✔
1185
    return pattern.dilate(ip, nbh) - pattern.erode(ip, nbh)
2✔
1186
end
1187

1188
--- Returns the morphological opening of the pattern.
1189
--- Performs erosion followed by dilation to remove small artifacts.
1190
---
1191
--- ```lua
1192
--- local opened = p:opening(neighbourhood.moore())
1193
--- ```
1194
---@param ip forma.pattern pattern to process
1195
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1196
---@return forma.pattern pattern a new, opened pattern
1197
function pattern.opening(ip, nbh)
1✔
1198
    nbh = nbh or neighbourhood.moore()
1✔
1199
    assert(getmetatable(ip) == pattern, "pattern.opening requires a pattern as the first argument")
1✔
1200
    assert(getmetatable(nbh) == neighbourhood, "pattern.opening requires a neighbourhood as the second argument")
1✔
1201
    local eroded = pattern.erode(ip, nbh)
1✔
1202
    return pattern.dilate(eroded, nbh)
1✔
1203
end
1204

1205
--- Returns the morphological closing of the pattern.
1206
--- Performs dilation followed by erosion to fill small holes.
1207
---
1208
--- ```lua
1209
--- local closed = p:closing(neighbourhood.moore())
1210
--- ```
1211
---@param ip forma.pattern pattern to process
1212
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1213
---@return forma.pattern pattern a new, closed pattern
1214
function pattern.closing(ip, nbh)
1✔
1215
    nbh = nbh or neighbourhood.moore()
1✔
1216
    assert(getmetatable(ip) == pattern, "pattern.closing requires a pattern as the first argument")
1✔
1217
    assert(getmetatable(nbh) == neighbourhood, "pattern.closing requires a neighbourhood as the second argument")
1✔
1218
    local dilated = pattern.dilate(ip, nbh)
1✔
1219
    return pattern.erode(dilated, nbh)
1✔
1220
end
1221

1222
--- Returns a pattern of cells that form the interior hull.
1223
--- These are cells that neighbor inactive cells while still belonging to the pattern.
1224
---
1225
--- ```lua
1226
--- local interior = p:interior_hull(neighbourhood.moore())
1227
--- ```
1228
---@param ip forma.pattern pattern to process
1229
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1230
---@return forma.pattern pattern a new pattern representing the interior hull
1231
function pattern.interior_hull(ip, nbh)
1✔
1232
    nbh = nbh or neighbourhood.moore()
3✔
1233
    assert(getmetatable(ip) == pattern, "pattern.interior_hull requires a pattern as the first argument")
3✔
1234
    assert(getmetatable(nbh) == neighbourhood, "pattern.interior_hull requires a neighbourhood as an argument")
3✔
1235
    return (ip - pattern.erode(ip, nbh))
3✔
1236
end
1237

1238
--- Returns a pattern of cells that form the exterior hull.
1239
--- This consists of inactive neighbours of the pattern, useful for enlarging or
1240
--- determining non-overlapping borders.
1241
---
1242
--- ```lua
1243
--- local exterior = p:exterior_hull(neighbourhood.moore())
1244
--- ```
1245
---@param ip forma.pattern pattern to process
1246
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1247
---@return forma.pattern pattern a new pattern representing the exterior hull
1248
function pattern.exterior_hull(ip, nbh)
1✔
1249
    nbh = nbh or neighbourhood.moore()
3✔
1250
    assert(getmetatable(ip) == pattern, "pattern.exterior_hull requires a pattern as the first argument")
3✔
1251
    assert(getmetatable(nbh) == neighbourhood, "pattern.exterior_hull requires a neighbourhood as an argument")
3✔
1252
    return (pattern.dilate(ip, nbh) - ip)
3✔
1253
end
1254

1255
-- Check if pattern a fits entirely within domain b when shifted by coordshift
1256
local function can_pack_at(a, b, coordshift)
1257
    for acell in a:cells() do
403✔
1258
        local shifted = acell + coordshift
202✔
1259
        if not b:has_cell(shifted.x, shifted.y) then
202✔
1260
            return false
1✔
1261
        end
1262
    end
1263
    return true
201✔
1264
end
1265

1266
-- @section Packing
1267

1268
--- Finds a packing offset where pattern a fits entirely within domain b.
1269
--- Returns a coordinate shift that, when applied to a, makes it tile inside b.
1270
---
1271
--- ```lua
1272
--- local offset = pattern.find_packing_position(p, domain)
1273
--- ```
1274
---@param a forma.pattern pattern to pack
1275
---@param b forma.pattern domain pattern in which to pack a
1276
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
1277
---@return forma.cell? shift a cell (as a coordinate shift) if a valid position is found; nil otherwise
1278
function pattern.find_packing_position(a, b, rng)
1✔
1279
    assert(getmetatable(a) == pattern, "pattern.find_packing_position requires a pattern as the first argument")
102✔
1280
    assert(getmetatable(b) == pattern, "pattern.find_packing_position requires a pattern as a second argument")
102✔
1281
    assert(a:size() > 0, "pattern.find_packing_position requires a non-empty pattern as the first argument")
102✔
1282
    local hinge = a:rcell(rng)
102✔
1283
    for bcell in b:shuffled_cells(rng) do
103✔
1284
        local coordshift = bcell - hinge
101✔
1285
        if can_pack_at(a, b, coordshift) then
101✔
1286
            return coordshift
100✔
1287
        end
1288
    end
1289
    return nil
2✔
1290
end
1291

1292
--- Finds a center-weighted packing offset to place pattern a within pattern
1293
--- b as close as possible to the cell c. If no `c` is provided, then the centroid
1294
--- of pattern b is used.
1295
---
1296
--- ```lua
1297
--- local central_offset = pattern.find_central_packing_position(p, domain, c)
1298
--- ```
1299
---@param a forma.pattern pattern to pack
1300
---@param b forma.pattern domain pattern
1301
---@param c? forma.cell cell to act as a center for packing
1302
---@return forma.cell? shift a coordinate shift if a valid position is found; nil otherwise
1303
function pattern.find_central_packing_position(a, b, c)
1✔
1304
    assert(getmetatable(a) == pattern, "pattern.find_central_packing_position requires a pattern as the first argument")
103✔
1305
    assert(getmetatable(b) == pattern, "pattern.find_central_packing_position requires a pattern as a second argument")
103✔
1306
    assert(a:size() > 0, "pattern.find_central_packing_position requires a non-empty pattern as the first argument")
103✔
1307
    if b:size() == 0 or a:size() > b:size() then return nil end
103✔
1308
    if c == nil then c = b:centroid() end
101✔
1309
    local hinge    = a:medoid()
101✔
1310
    local allcells = b:cell_list()
101✔
1311
    local function distance_to_c(k, j)
1312
        local adist = (k.x - c.x) * (k.x - c.x) + (k.y - c.y) * (k.y - c.y)
28,626✔
1313
        local bdist = (j.x - c.x) * (j.x - c.x) + (j.y - c.y) * (j.y - c.y)
28,626✔
1314
        return adist < bdist
28,626✔
1315
    end
1316
    table.sort(allcells, distance_to_c)
101✔
1317
    for i = 1, #allcells do
101✔
1318
        local coordshift = allcells[i] - hinge
101✔
1319
        if can_pack_at(a, b, coordshift) then
101✔
1320
            return coordshift
101✔
1321
        end
1322
    end
UNCOV
1323
    return nil
×
1324
end
1325

1326
-- @section Decomposition
1327

1328
--- Returns a multipattern of the connected components within the pattern.
1329
--- Uses flood-fill to extract contiguous subpatterns.
1330
---
1331
--- ```lua
1332
--- local components = p:connected_components(neighbourhood.moore())
1333
--- ```
1334
---@param ip forma.pattern pattern to analyze
1335
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1336
---@return forma.multipattern multipattern containing each connected component as a subpattern
1337
function pattern.connected_components(ip, nbh)
1✔
1338
    nbh = nbh or neighbourhood.moore()
19✔
1339
    assert(getmetatable(ip) == pattern, "pattern.connected_components requires a pattern as the first argument")
19✔
1340
    assert(getmetatable(nbh) == neighbourhood,
38✔
1341
        "pattern.connected_components requires a neighbourhood as the second argument")
19✔
1342
    local wp = pattern.clone(ip)
19✔
1343
    local mp = multipattern.new()
19✔
1344
    while pattern.size(wp) > 0 do
90✔
1345
        local seed_cell = assert(wp:cells()())
71✔
1346
        local segment = pattern.floodfill(wp, seed_cell, nbh)
71✔
1347
        mp:insert(segment)
71✔
1348
        for x, y in segment:cell_coordinates() do
1,623✔
1349
            wp:remove(x, y)
1,552✔
1350
        end
1351
    end
1352
    return mp
19✔
1353
end
1354

1355
--- Returns a multipattern of the interior holes of the pattern.
1356
--- Interior holes are inactive regions completely surrounded by active cells.
1357
---
1358
--- ```lua
1359
--- local holes = p:interior_holes(neighbourhood.von_neumann())
1360
--- ```
1361
---@param ip forma.pattern pattern to analyze
1362
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.von_neumann())
1363
---@return forma.multipattern multipattern of interior hole subpatterns
1364
function pattern.interior_holes(ip, nbh)
1✔
1365
    nbh = nbh or neighbourhood.von_neumann()
13✔
1366
    assert(getmetatable(ip) == pattern, "pattern.interior_holes requires a pattern as the first argument")
13✔
1367
    assert(ip:size() > 0, "pattern.interior_holes requires a non-empty pattern as the first argument")
13✔
1368
    assert(getmetatable(nbh) == neighbourhood, "pattern.interior_holes requires a neighbourhood as the second argument")
13✔
1369
    local primitives = require('forma.primitives')
13✔
1370
    local size = ip.max - ip.min + cell.new(1, 1)
13✔
1371
    local interior = primitives.square(size.x, size.y):translate(ip.min.x, ip.min.y) - ip
13✔
1372
    local connected_components = pattern.connected_components(interior, nbh)
13✔
1373
    local function fn(sp)
1374
        if sp.min.x > ip.min.x and sp.min.y > ip.min.y and sp.max.x < ip.max.x and sp.max.y < ip.max.y then
58✔
1375
            return true
14✔
1376
        end
1377
        return false
44✔
1378
    end
1379
    return connected_components:filter(fn)
13✔
1380
end
1381

1382
--- Partitions the pattern using binary space partitioning (BSP).
1383
--- Recursively subdivides contiguous rectangular areas until each partition's volume is below th_volume.
1384
---
1385
--- ```lua
1386
--- local partitions = p:bsp(50)
1387
--- ```
1388
---@param ip forma.pattern pattern to partition
1389
---@param th_volume number threshold volume for final partitions
1390
---@param alpha? number parameter for squareness of BSP
1391
---@return forma.multipattern multipattern of BSP subpatterns
1392
function pattern.bsp(ip, th_volume, alpha)
1✔
1393
    assert(getmetatable(ip) == pattern, "pattern.bsp requires a pattern as an argument")
2✔
1394
    assert(th_volume, "pattern.bsp rules must specify a threshold volume for partitioning")
2✔
1395
    assert(th_volume > 0, "pattern.bsp rules must specify positive threshold volume for partitioning")
2✔
1396
    local available = ip:clone()
2✔
1397
    local mp = multipattern.new()
2✔
1398
    local bsp = require('forma.utils.bsp')
2✔
1399
    while pattern.size(available) > 0 do
4✔
1400
        local min_rect, max_rect = bsp.max_rectangle_coordinates(available, alpha)
2✔
1401
        if max_rect.x < min_rect.x then break end
2✔
1402
        bsp.split(min_rect, max_rect, th_volume, mp)
2✔
1403
        available = available - mp:union_all()
2✔
1404
    end
1405
    return mp
2✔
1406
end
1407

1408
--- Categorizes cells in the pattern based on neighbourhood configurations.
1409
--- Returns a multipattern with one subpattern per neighbourhood category.
1410
---
1411
--- ```lua
1412
--- local categories = p:neighbourhood_categories(neighbourhood.moore())
1413
--- ```
1414
---@param ip forma.pattern pattern whose cells are to be categorized
1415
---@param nbh forma.neighbourhood neighbourhood used for categorization
1416
---@return forma.multipattern multipattern with each category represented as a subpattern
1417
function pattern.neighbourhood_categories(ip, nbh)
1✔
1418
    assert(getmetatable(ip) == pattern, "pattern.neighbourhood_categories requires a pattern as a first argument")
2✔
1419
    assert(getmetatable(nbh) == neighbourhood,
4✔
1420
        "pattern.neighbourhood_categories requires a neighbourhood as a second argument")
2✔
1421
    local category_patterns = {}
2✔
1422
    for i = 1, nbh:get_ncategories(), 1 do
274✔
1423
        category_patterns[i] = pattern.new()
272✔
1424
    end
1425
    for icell in ip:cells() do
82✔
1426
        local cat = nbh:categorise(ip, icell)
80✔
1427
        category_patterns[cat]:insert(icell.x, icell.y)
80✔
1428
    end
1429
    return multipattern.new(category_patterns)
2✔
1430
end
1431

1432
--- Applies Perlin noise sampling to the pattern.
1433
--- Generates a multipattern by thresholding Perlin noise values at multiple levels.
1434
---
1435
--- ```lua
1436
--- local noise_samples = p:perlin(0.1, 4, {0.3, 0.5, 0.7})
1437
--- ```
1438
---@param ip forma.pattern pattern (domain) to sample from
1439
---@param freq number frequency for Perlin noise
1440
---@param depth integer sampling depth
1441
---@param thresholds number[] table of threshold values (each between 0 and 1)
1442
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
1443
---@return forma.multipattern multipattern with one component per threshold level
1444
function pattern.perlin(ip, freq, depth, thresholds, rng)
1✔
1445
    if rng == nil then rng = math.random end
1✔
1446
    assert(getmetatable(ip) == pattern, "pattern.perlin requires a pattern as the first argument")
1✔
1447
    assert(type(freq) == "number", "pattern.perlin requires a numerical frequency value.")
1✔
1448
    assert(math.floor(depth) == depth, "pattern.perlin requires an integer sampling depth.")
1✔
1449
    assert(type(thresholds) == "table", "pattern.perlin requires a table of requested thresholds.")
1✔
1450
    for _, th in ipairs(thresholds) do
5✔
1451
        assert(th >= 0 and th <= 1, "pattern.perlin requires thresholds between 0 and 1.")
4✔
1452
    end
1453
    local samples = {}
1✔
1454
    for i = 1, #thresholds, 1 do
5✔
1455
        samples[i] = pattern.new()
4✔
1456
    end
1457
    local noise = require('forma.utils.noise')
1✔
1458
    local p_noise = noise.init(rng)
1✔
1459
    for ix, iy in ip:cell_coordinates() do
1,601✔
1460
        local nv = noise.perlin(p_noise, ix, iy, freq, depth)
1,600✔
1461
        for ith, th in ipairs(thresholds) do
8,000✔
1462
            if nv >= th then
6,400✔
1463
                samples[ith]:insert(ix, iy)
3,059✔
1464
            end
1465
        end
1466
    end
1467
    return multipattern.new(samples)
1✔
1468
end
1469

1470
--- Generates Voronoi tessellation segments for a domain based on seed points.
1471
---
1472
--- ```lua
1473
--- local segments = pattern.voronoi(seeds, domain, cell.euclidean)
1474
--- ```
1475
---@param seeds forma.pattern pattern containing seed cells
1476
---@param domain forma.pattern pattern defining the tessellation domain
1477
---@param measure fun(a: forma.cell, b: forma.cell): number distance function (e.g., cell.euclidean)
1478
---@return forma.multipattern multipattern of Voronoi segments
1479
function pattern.voronoi(seeds, domain, measure)
1✔
1480
    assert(getmetatable(seeds) == pattern, "pattern.voronoi requires a pattern as the first argument")
7✔
1481
    assert(getmetatable(domain) == pattern, "pattern.voronoi requires a pattern as a second argument")
7✔
1482
    assert(pattern.size(seeds) > 0, "pattern.voronoi requires at least one target cell/seed")
7✔
1483
    local seedcells = {}
7✔
1484
    local segments  = {}
7✔
1485
    for iseed in seeds:cells() do
77✔
1486
        assert(domain:has_cell(iseed.x, iseed.y), "forma.voronoi: cell outside of domain")
70✔
1487
        table.insert(seedcells, iseed)
70✔
1488
        table.insert(segments, pattern.new())
70✔
1489
    end
1490
    for dp in domain:cells() do
707✔
1491
        local min_cell = 1
700✔
1492
        local min_dist = measure(dp, seedcells[1])
700✔
1493
        for j = 2, #seedcells, 1 do
7,000✔
1494
            local distance = measure(dp, seedcells[j])
6,300✔
1495
            if distance < min_dist then
6,300✔
1496
                min_cell = j
1,027✔
1497
                min_dist = distance
1,027✔
1498
            end
1499
        end
1500
        segments[min_cell]:insert(dp.x, dp.y)
700✔
1501
    end
1502
    return multipattern.new(segments)
7✔
1503
end
1504

1505
--- Performs centroidal Voronoi tessellation (Lloyd's algorithm) on a set of seeds.
1506
--- Iteratively relaxes seed positions until convergence or a maximum number of iterations.
1507
---
1508
--- ```lua
1509
--- local segments, relaxed_seeds, converged = pattern.voronoi_relax(seeds, domain, cell.euclidean)
1510
--- ```
1511
---@param seeds forma.pattern initial seed pattern
1512
---@param domain forma.pattern tessellation domain pattern
1513
---@param measure fun(a: forma.cell, b: forma.cell): number distance function (e.g., cell.euclidean)
1514
---@param max_ite? integer maximum iterations (default: 30)
1515
---@return forma.multipattern segments a multipattern of Voronoi segments
1516
---@return forma.pattern seeds a pattern of relaxed seed positions
1517
---@return boolean converged whether the algorithm converged
1518
function pattern.voronoi_relax(seeds, domain, measure, max_ite)
1✔
1519
    if max_ite == nil then max_ite = 30 end
1✔
1520
    assert(getmetatable(seeds) == pattern, "pattern.voronoi_relax requires a pattern as the first argument")
1✔
1521
    assert(getmetatable(domain) == pattern, "pattern.voronoi_relax requires a pattern as a second argument")
1✔
1522
    assert(type(measure) == 'function', "pattern.voronoi_relax requires a distance measure as an argument")
1✔
1523
    assert(seeds:size() <= domain:size(), "pattern.voronoi_relax: too many seeds for domain")
1✔
1524
    local current_seeds = seeds:clone()
1✔
1525
    for ite = 1, max_ite, 1 do
4✔
1526
        local tesselation = pattern.voronoi(current_seeds, domain, measure)
4✔
1527
        local next_seeds  = pattern.new()
4✔
1528
        for iseg = 1, tesselation:n_components(), 1 do
44✔
1529
            if tesselation[iseg]:size() > 0 then
40✔
1530
                local cent = tesselation[iseg]:centroid()
40✔
1531
                if domain:has_cell(cent.x, cent.y) then
40✔
1532
                    if not next_seeds:has_cell(cent.x, cent.y) then
40✔
1533
                        next_seeds:insert(cent.x, cent.y)
40✔
1534
                    end
1535
                else
UNCOV
1536
                    local med = tesselation[iseg]:medoid()
×
UNCOV
1537
                    if not next_seeds:has_cell(med.x, med.y) then
×
UNCOV
1538
                        next_seeds:insert(med.x, med.y)
×
1539
                    end
1540
                end
1541
            end
1542
        end
1543
        if current_seeds == next_seeds then
4✔
1544
            return tesselation, current_seeds, true
1✔
1545
        elseif ite == max_ite then
3✔
UNCOV
1546
            return tesselation, current_seeds, false
×
1547
        end
1548
        current_seeds = next_seeds
3✔
1549
    end
NEW
1550
    error("This should not be reachable")
×
1551
end
1552

1553
-- @section Display and utilities
1554

1555
--- Returns the maximum allowed coordinate for spatial hashing.
1556
---
1557
---@return number max_coordinate maximum coordinate value
1558
function pattern.get_max_coordinate()
1✔
1559
    return MAX_COORDINATE
1✔
1560
end
1561

1562
--- Tests the conversion between (x, y) coordinates and the spatial hash key.
1563
---
1564
---@param x number test x-coordinate
1565
---@param y number test y-coordinate
1566
---@return boolean valid true if the conversion is correct, false otherwise
1567
function pattern.test_coordinate_map(x, y)
1✔
1568
    assert(type(x) == 'number' and type(y) == 'number',
20✔
1569
        "pattern.test_coordinate_map requires two numbers as arguments")
10✔
1570
    local key = coordinates_to_key(x, y)
10✔
1571
    local tx, ty = key_to_coordinates(key)
10✔
1572
    return (x == tx) and (y == ty)
10✔
1573
end
1574

1575
--- Prints the pattern within an optional domain, line-by-line.
1576
---
1577
---@param ip forma.pattern pattern to render
1578
---@param char? string single-character to draw "on" cells
1579
---@param domain? forma.pattern pattern defining the bounding box
1580
---@param printer? fun(line: string) function for printing each line; defaults to io.write(line.."\n")
1581
function pattern.print(ip, char, domain, printer)
1✔
1582
    assert(getmetatable(ip) == pattern, "pattern.print requires a pattern as the first argument")
1✔
1583
    local onchar  = char or ip.onchar
1✔
1584
    local offchar = ip.offchar
1✔
1585
    domain        = domain or ip
1✔
1586
    assert(getmetatable(domain) == pattern, "pattern.print requires a pattern as the domain argument")
1✔
1587

UNCOV
1588
    local print_line = printer
×
1589
        or function(line) io.write(line .. "\n") end
1✔
1590

1591
    for y = domain.min.y, domain.max.y, 1 do
2✔
1592
        local chars = {}
1✔
1593
        for x = domain.min.x, domain.max.x, 1 do
2✔
1594
            chars[#chars + 1] = ip:has_cell(x, y) and onchar or offchar
1✔
1595
        end
1596
        print_line(table.concat(chars))
1✔
1597
    end
1598
end
1599

1600
return pattern
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc