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

nhartland / forma / 23411507787

22 Mar 2026 08:11PM UTC coverage: 98.215% (+0.002%) from 98.213%
23411507787

Pull #32

github

web-flow
Merge 865f7034f into 7995fe40d
Pull Request #32: MKdocs rather than Ldoc for documentation generation

9 of 11 new or added lines in 4 files covered. (81.82%)

36 existing lines in 4 files now uncovered.

2091 of 2129 relevant lines covered (98.22%)

8575.81 hits per line

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

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

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

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

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

110
    np = setmetatable(np, pattern)
7,319✔
111

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

137
    return np
7,319✔
138
end
139

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

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

152
    newpat.offchar = ip.offchar
1,338✔
153
    newpat.onchar  = ip.onchar
1,338✔
154

155
    return newpat
1,338✔
156
end
157

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

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

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

184
    return ip
189,172✔
185
end
186

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

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

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

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

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

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

231
    return ip
2,243✔
232
end
233

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

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

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

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

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

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

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

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

331
    local x, y
332
    if type(arg1) == 'table' and arg1.x and arg1.y then
476✔
UNCOV
333
        x, y = arg1.x, arg1.y
×
334
    else
335
        x, y = arg1, arg2
476✔
336
    end
337

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

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

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

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

388
    if #patterns == 0 then return pattern.new() end
35✔
389
    if #patterns == 1 then return patterns[1]:clone() end
35✔
390

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

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

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

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

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

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

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

543
--- Renders the pattern as a string.
544
--- Active cells are shown with pattern.onchar and inactive cells with pattern.offchar.
545
---
546
--- ```lua
547
--- print(p)
548
--- ```
549
---@param ip forma.pattern pattern to render
550
---@return string str string representation of the pattern
551
function pattern.__tostring(ip)
1✔
552
    local str = '- pattern origin: ' .. tostring(ip.min) .. '\n'
1✔
553
    for y = ip.min.y, ip.max.y, 1 do
6✔
554
        for x = ip.min.x, ip.max.x, 1 do
30✔
555
            local char = ip:has_cell(x, y) and ip.onchar or ip.offchar
25✔
556
            str = str .. char
25✔
557
        end
558
        str = str .. '\n'
5✔
559
    end
560
    return str
1✔
561
end
562

563
--- Adds two patterns using the '+' operator (i.e. returns their union).
564
---
565
--- ```lua
566
--- local combined = p1 + p2
567
--- ```
568
---@param a forma.pattern first pattern
569
---@param b forma.pattern second pattern
570
---@return forma.pattern pattern a new pattern representing the union of a and b
571
function pattern.__add(a, b)
1✔
572
    assert(getmetatable(a) == pattern, "pattern addition requires a pattern as the first argument")
23✔
573
    assert(getmetatable(b) == pattern, "pattern addition requires a pattern as the second argument")
23✔
574
    return pattern.union(a, b)
23✔
575
end
576

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

598
--- Computes the intersection of two patterns using the '*' operator.
599
---
600
--- ```lua
601
--- local common = p1 * p2
602
--- ```
603
---@param a forma.pattern first pattern
604
---@param b forma.pattern second pattern
605
---@return forma.pattern pattern a new pattern containing only the cells common to both
606
function pattern.__mul(a, b)
1✔
607
    assert(getmetatable(a) == pattern, "pattern multiplication requires a pattern as the first argument")
4✔
608
    assert(getmetatable(b) == pattern, "pattern multiplication requires a pattern as the second argument")
4✔
609
    return pattern.intersect(a, b)
4✔
610
end
611

612
--- Computes the symmetric difference (XOR) of two patterns using the '^' operator.
613
---
614
--- ```lua
615
--- local xor_pattern = p1 ^ p2
616
--- ```
617
---@param a forma.pattern first pattern
618
---@param b forma.pattern second pattern
619
---@return forma.pattern pattern a new pattern with cells present in either a or b, but not both
620
function pattern.__pow(a, b)
1✔
621
    assert(getmetatable(a) == pattern, "pattern exponent (XOR) requires a pattern as the first argument")
1✔
622
    assert(getmetatable(b) == pattern, "pattern exponent (XOR) requires a pattern as the second argument")
1✔
623
    return pattern.xor(a, b)
1✔
624
end
625

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

650
--- Computes the centroid (arithmetic mean) of all cells in the pattern.
651
--- The result is rounded to the nearest integer coordinate.
652
---
653
--- ```lua
654
--- local center = p:centroid()
655
--- ```
656
---@param ip forma.pattern pattern to process
657
---@return forma.cell centroid a cell representing the centroid (which may not be active)
658
function pattern.centroid(ip)
1✔
659
    assert(getmetatable(ip) == pattern, "pattern.centroid requires a pattern as the first argument")
147✔
660
    assert(ip:size() > 0, 'pattern.centroid requires a filled pattern!')
147✔
661
    local sumx, sumy = 0, 0
147✔
662
    for x, y in ip:cell_coordinates() do
5,622✔
663
        sumx = sumx + x
5,475✔
664
        sumy = sumy + y
5,475✔
665
    end
666
    local n = ip:size()
147✔
667
    local intx = floor(sumx / n + 0.5)
147✔
668
    local inty = floor(sumy / n + 0.5)
147✔
669
    return cell.new(intx, inty)
147✔
670
end
671

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

705
--- Returns a random cell from the pattern.
706
---
707
--- ```lua
708
--- local random_cell = p:rcell()
709
--- ```
710
---@param ip forma.pattern pattern to sample from
711
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
712
---@return forma.cell cell a random cell from the pattern
713
function pattern.rcell(ip, rng)
1✔
714
    assert(getmetatable(ip) == pattern, "pattern.rcell requires a pattern as the first argument")
508✔
715
    assert(ip:size() > 0, 'pattern.rcell requires a filled pattern!')
508✔
716
    if rng == nil then rng = math.random end
508✔
717
    local icell = rng(#ip.cellkey)
508✔
718
    local ikey = ip.cellkey[icell]
508✔
719
    local x, y = key_to_coordinates(ikey)
508✔
720
    return cell.new(x, y)
508✔
721
end
722

723
--- Returns a new pattern translated by a vector (sx, sy).
724
---
725
--- ```lua
726
--- local p_translated = p:translate(2, 3)
727
--- ```
728
---@param ip forma.pattern pattern to translate
729
---@param sx integer translation along the x-axis
730
---@param sy integer translation along the y-axis
731
---@return forma.pattern pattern a new pattern shifted by (sx, sy)
732
function pattern.translate(ip, sx, sy)
1✔
733
    assert(getmetatable(ip) == pattern, "pattern.translate requires a pattern as the first argument")
1,304✔
734
    assert(floor(sx) == sx, 'pattern.translate requires an integer for the x coordinate')
1,304✔
735
    assert(floor(sy) == sy, 'pattern.translate requires an integer for the y coordinate')
1,304✔
736
    local sp = pattern.new()
1,304✔
737
    for tx, ty in ip:cell_coordinates() do
13,619✔
738
        local nx = tx + sx
12,315✔
739
        local ny = ty + sy
12,315✔
740
        sp:insert(nx, ny)
12,315✔
741
    end
742
    return sp
1,304✔
743
end
744

745
--- Normalizes the pattern by translating it so that its minimum coordinate is (0,0).
746
---
747
--- ```lua
748
--- local p_norm = p:normalise()
749
--- ```
750
---@param ip forma.pattern pattern to normalize
751
---@return forma.pattern pattern a new normalized pattern
752
function pattern.normalise(ip)
1✔
753
    assert(getmetatable(ip) == pattern, "pattern.normalise requires a pattern as the first argument")
4✔
754
    return ip:translate(-ip.min.x, -ip.min.y)
4✔
755
end
756

757
--- Returns an enlarged version of the pattern.
758
--- Each active cell is replaced by an f×f block.
759
---
760
--- ```lua
761
--- local p_big = p:enlarge(2)
762
--- ```
763
---@param ip forma.pattern pattern to enlarge
764
---@param f number enlargement factor
765
---@return forma.pattern pattern a new enlarged pattern
766
function pattern.enlarge(ip, f)
1✔
767
    assert(getmetatable(ip) == pattern, "pattern.enlarge requires a pattern as the first argument")
4✔
768
    assert(type(f) == 'number', 'pattern.enlarge requires a number as the enlargement factor')
4✔
769
    local ep = pattern.new()
4✔
770
    for icell in ip:cells() do
34✔
771
        local sv = cell.new(f * icell.x, f * icell.y)
30✔
772
        for i = 0, f - 1, 1 do
90✔
773
            for j = 0, f - 1, 1 do
180✔
774
                ep:insert(sv.x + i, sv.y + j)
120✔
775
            end
776
        end
777
    end
778
    return ep
4✔
779
end
780

781
--- Returns a new pattern rotated 90° clockwise about the origin.
782
---
783
--- ```lua
784
--- local p_rotated = p:rotate()
785
--- ```
786
---@param ip forma.pattern pattern to rotate
787
---@return forma.pattern pattern a rotated pattern
788
function pattern.rotate(ip)
1✔
789
    assert(getmetatable(ip) == pattern, "pattern.rotate requires a pattern as the first argument")
6✔
790
    local np = pattern.new()
6✔
791
    for x, y in ip:cell_coordinates() do
46✔
792
        np:insert(y, -x)
40✔
793
    end
794
    return np
6✔
795
end
796

797
--- Returns a new pattern that is a vertical reflection of the original.
798
---
799
--- ```lua
800
--- local p_vreflected = p:vreflect()
801
--- ```
802
---@param ip forma.pattern pattern to reflect vertically
803
---@return forma.pattern pattern a vertically reflected pattern
804
function pattern.vreflect(ip)
1✔
805
    assert(getmetatable(ip) == pattern, "pattern.vreflect requires a pattern as the first argument")
2✔
806
    local np = pattern.new()
2✔
807
    for vx, vy in ip:cell_coordinates() do
6✔
808
        local new_y = ip.min.y + ip.max.y - vy
4✔
809
        np:insert(vx, new_y)
4✔
810
    end
811
    return np
2✔
812
end
813

814
--- Returns a new pattern that is a horizontal reflection of the original.
815
---
816
--- ```lua
817
--- local p_hreflected = p:hreflect()
818
--- ```
819
---@param ip forma.pattern pattern to reflect horizontally
820
---@return forma.pattern pattern a horizontally reflected pattern
821
function pattern.hreflect(ip)
1✔
822
    assert(getmetatable(ip) == pattern, "pattern.hreflect requires a pattern as the first argument")
2✔
823
    local np = pattern.new()
2✔
824
    for vx, vy in ip:cell_coordinates() do
6✔
825
        local new_x = ip.min.x + ip.max.x - vx
4✔
826
        np:insert(new_x, vy)
4✔
827
    end
828
    return np
2✔
829
end
830

831
--- Returns a random subpattern containing a fixed number of cells.
832
---
833
--- ```lua
834
--- local sample = p:sample(10)
835
--- ```
836
---@param ip forma.pattern pattern (domain) to sample from
837
---@param ncells integer number of cells to sample
838
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
839
---@return forma.pattern pattern a new pattern with ncells randomly selected cells
840
function pattern.sample(ip, ncells, rng)
1✔
841
    assert(getmetatable(ip) == pattern, "pattern.sample requires a pattern as the first argument")
18✔
842
    assert(type(ncells) == 'number', "pattern.sample requires an integer number of cells as the second argument")
18✔
843
    assert(math.floor(ncells) == ncells, "pattern.sample requires an integer number of cells as the second argument")
18✔
844
    assert(ncells > 0, "pattern.sample requires at least one sample to be requested")
18✔
845
    assert(ncells <= ip:size(), "pattern.sample requires a domain larger than the number of requested samples")
18✔
846
    if rng == nil then rng = math.random end
18✔
847
    local p = pattern.new()
18✔
848
    local next_coords = ip:shuffled_coordinates(rng)
18✔
849
    for _ = 1, ncells, 1 do
228✔
850
        local x, y = next_coords()
210✔
851
        assert(x and y)
210✔
852
        p:insert(x, y)
210✔
853
    end
854
    return p
18✔
855
end
856

857
--- Returns a Poisson-disc sampled subpattern.
858
--- Ensures that no two sampled cells are closer than the given radius.
859
---
860
--- ```lua
861
--- local poisson_sample = p:sample_poisson(cell.euclidean, 5)
862
--- ```
863
---@param ip forma.pattern pattern (domain) to sample from
864
---@param distance fun(a: forma.cell, b: forma.cell): number distance function (e.g., cell.euclidean)
865
---@param radius number minimum separation
866
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
867
---@return forma.pattern pattern a new pattern sampled with Poisson-disc criteria
868
function pattern.sample_poisson(ip, distance, radius, rng)
1✔
869
    assert(getmetatable(ip) == pattern, "pattern.sample_poisson requires a pattern as the first argument")
1✔
870
    assert(type(distance) == 'function', "pattern.sample_poisson requires a distance measure as an argument")
1✔
871
    assert(type(radius) == "number", "pattern.sample_poisson requires a number as the target radius")
1✔
872
    if rng == nil then rng = math.random end
1✔
873
    local sample = pattern.new()
1✔
874
    local domain = ip:clone()
1✔
875
    while domain:size() > 0 do
10✔
876
        local dart = domain:rcell(rng)
9✔
877
        sample:insert(dart.x, dart.y)
9✔
878
        local to_remove = {}
9✔
879
        for cell_to_check in domain:cells() do
373✔
880
            if distance(cell_to_check, dart) < radius then
364✔
881
                table.insert(to_remove, cell_to_check)
100✔
882
            end
883
        end
884
        for _, cell_to_remove in ipairs(to_remove) do
109✔
885
            domain:remove(cell_to_remove.x, cell_to_remove.y)
100✔
886
        end
887
    end
888
    return sample
1✔
889
end
890

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

937
--- Returns the contiguous subpattern (connected component) starting from a given location.
938
local function floodfill(x, y, nbh, domain, retpat)
939
    local q = { x, y }
727✔
940
    local head = 1
727✔
941
    local tail = 2
727✔
942
    retpat:insert(x, y)
727✔
943

944
    while head < tail do
21,281✔
945
        local cx, cy = q[head], q[head + 1]
20,554✔
946
        head = head + 2
20,554✔
947

948
        for i = 1, #nbh do
175,386✔
949
            local nx = nbh[i].x + cx
154,832✔
950
            local ny = nbh[i].y + cy
154,832✔
951
            if domain:has_cell(nx, ny) and not retpat:has_cell(nx, ny) then
154,832✔
952
                retpat:insert(nx, ny)
19,827✔
953
                q[tail + 1] = nx
19,827✔
954
                q[tail + 2] = ny
19,827✔
955
                tail = tail + 2
19,827✔
956
            end
957
        end
958
    end
959
end
960

961
--- Returns the contiguous subpattern (connected component) starting from a given cell.
962
---
963
--- ```lua
964
--- local component = p:floodfill(cell.new(2, 3))
965
--- ```
966
---@param ip forma.pattern pattern upon which the flood fill is to be performed
967
---@param icell forma.cell a cell specifying the origin of the flood fill
968
---@param nbh? forma.neighbourhood neighbourhood to use (default: neighbourhood.moore())
969
---@return forma.pattern pattern a new pattern containing the connected component
970
function pattern.floodfill(ip, icell, nbh)
1✔
971
    assert(getmetatable(ip) == pattern, "pattern.floodfill requires a pattern as the first argument")
727✔
972
    assert(icell, "pattern.floodfill requires a cell as the second argument")
727✔
973
    if nbh == nil then nbh = neighbourhood.moore() end
727✔
974
    local retpat = pattern.new()
727✔
975
    if ip:has_cell(icell.x, icell.y) then
727✔
976
        floodfill(icell.x, icell.y, nbh, ip, retpat)
727✔
977
    end
978
    return retpat
727✔
979
end
980

981
--- Finds the largest contiguous rectangular subpattern within the pattern.
982
---
983
--- ```lua
984
--- local rect = p:max_rectangle()
985
--- ```
986
---@param ip forma.pattern pattern to analyze
987
---@param alpha? number 'squareness' parameter. 0 for max rectangle, 1 for max square
988
---@return forma.pattern pattern a subpattern representing the maximal rectangle
989
function pattern.max_rectangle(ip, alpha)
1✔
990
    assert(getmetatable(ip) == pattern, "pattern.max_rectangle requires a pattern as an argument")
2✔
991
    local primitives = require('forma.primitives')
2✔
992
    local bsp = require('forma.utils.bsp')
2✔
993
    local min_rect, max_rect = bsp.max_rectangle_coordinates(ip, alpha)
2✔
994
    local size = max_rect - min_rect + cell.new(1, 1)
2✔
995
    return primitives.square(size.x, size.y):translate(min_rect.x, min_rect.y)
2✔
996
end
997

998
--- Computes the convex hull of the pattern.
999
--- The hull points are connected using line rasterization.
1000
---
1001
--- ```lua
1002
--- local hull = p:convex_hull()
1003
--- ```
1004
---@param ip forma.pattern pattern to process
1005
---@return forma.pattern pattern a new pattern representing the convex hull
1006
function pattern.convex_hull(ip)
1✔
1007
    assert(getmetatable(ip) == pattern, "pattern.convex_hull requires a pattern as the first argument")
1✔
1008
    assert(ip:size() > 0, "pattern.convex_hull: input pattern must have at least one cell")
1✔
1009
    local convex_hull = require('forma.utils.convex_hull')
1✔
1010
    local primitives = require('forma.primitives')
1✔
1011
    local hull_points = convex_hull.points(ip)
1✔
1012
    local chull = pattern.new()
1✔
1013
    local function add_line(p1, p2)
1014
        local line = primitives.line(p1, p2)
4✔
1015
        for x, y in line:cell_coordinates() do
24✔
1016
            if not chull:has_cell(x, y) then
20✔
1017
                chull:insert(x, y)
16✔
1018
            end
1019
        end
1020
    end
1021
    for i = 1, #hull_points - 1, 1 do
4✔
1022
        add_line(hull_points[i], hull_points[i + 1])
3✔
1023
    end
1024
    add_line(hull_points[#hull_points], hull_points[1])
1✔
1025
    return chull
1✔
1026
end
1027

1028
--- Returns a thinned (skeletonized) version of the pattern.
1029
--- Iteratively removes border cells whose Moore neighbours remain a single
1030
--- connected component under `nbh`.
1031
---
1032
--- ```lua
1033
--- local thin_p = p:thin()
1034
--- local thin_4 = p:thin(neighbourhood.von_neumann())
1035
--- ```
1036
---@param ip forma.pattern pattern to thin
1037
---@param nbh? forma.neighbourhood neighbourhood for connectivity (default: neighbourhood.moore())
1038
---@return forma.pattern pattern a new, thinned pattern
1039
function pattern.thin(ip, nbh)
1✔
1040
    assert(getmetatable(ip) == pattern,
8✔
1041
        "pattern.thin requires a pattern as the first argument")
4✔
1042
    nbh = nbh or neighbourhood.moore()
4✔
1043
    assert(getmetatable(nbh) == neighbourhood,
8✔
1044
        "pattern.thin requires a neighbourhood as the second argument")
4✔
1045

1046
    -- Moore neighbourhood used for border detection and local pruning
1047
    local moore = neighbourhood.moore()
4✔
1048
    -- Each iteration does one sub-pass per cardinal direction, only removing
1049
    -- cells exposed on that side.
1050
    local von_neumann = neighbourhood.von_neumann()
4✔
1051

1052
    local p = ip:clone()
4✔
1053
    local function can_delete(x, y, border_cell)
1054
        -- Cell must be exposed on the border side
1055
        if p:has_cell(x + border_cell.x, y + border_cell.y) then return false end
3,769✔
1056
        -- Don't delete endpoints
1057
        if pattern.count_neighbors(p, nbh, x, y) < 2 then return false end
476✔
1058
        -- All Moore neighbours must form a single nbh-connected component
1059
        local nbrs = pattern.new()
445✔
1060
        local seed = nil
445✔
1061
        for i = 1, #moore do
4,005✔
1062
            if p:has_cell(x + moore[i].x, y + moore[i].y) then
3,560✔
1063
                nbrs:insert(moore[i].x, moore[i].y)
1,889✔
1064
                seed = seed or cell.new(moore[i].x, moore[i].y)
1,889✔
1065
            end
1066
        end
1067
        assert(seed)
445✔
1068
        return pattern.floodfill(nbrs, seed, nbh):size() == nbrs:size()
445✔
1069
    end
1070
    local changed = true
4✔
1071
    while changed do
19✔
1072
        changed = false
15✔
1073
        for _, border_cell in ipairs(von_neumann) do
75✔
1074
            local to_remove = {}
60✔
1075
            for x, y in p:cell_coordinates() do
3,829✔
1076
                if can_delete(x, y, border_cell) then
3,769✔
1077
                    to_remove[#to_remove + 1] = { x, y }
385✔
1078
                end
1079
            end
1080
            for _, xy in ipairs(to_remove) do
445✔
1081
                p:remove(xy[1], xy[2])
385✔
1082
                changed = true
385✔
1083
            end
1084
        end
1085
    end
1086

1087
    return p
4✔
1088
end
1089

1090
--- Returns the erosion of the pattern.
1091
--- A cell is retained only if all of its neighbours (as defined by nbh) are active.
1092
---
1093
--- ```lua
1094
--- local eroded = p:erode(neighbourhood.moore())
1095
--- ```
1096
---@param ip forma.pattern pattern to erode
1097
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1098
---@return forma.pattern pattern a new, eroded pattern
1099
function pattern.erode(ip, nbh)
1✔
1100
    nbh = nbh or neighbourhood.moore()
8✔
1101
    assert(getmetatable(ip) == pattern, "pattern.erode requires a pattern as the first argument")
8✔
1102
    assert(getmetatable(nbh) == neighbourhood, "pattern.erode requires a neighbourhood as the second argument")
8✔
1103
    local result = pattern.new()
8✔
1104
    for x, y in ip:cell_coordinates() do
105✔
1105
        local keep = true
97✔
1106
        for j = 1, #nbh, 1 do
300✔
1107
            local offset = nbh[j]
287✔
1108
            local nx = x + offset.x
287✔
1109
            local ny = y + offset.y
287✔
1110
            if not ip:has_cell(nx, ny) then
287✔
1111
                keep = false
84✔
1112
                break
84✔
1113
            end
1114
        end
1115
        if keep then
97✔
1116
            result:insert(x, y)
13✔
1117
        end
1118
    end
1119
    return result
8✔
1120
end
1121

1122
--- Returns the dilation of the pattern.
1123
--- Each active cell contributes its neighbours (as defined by nbh) to the result.
1124
---
1125
--- ```lua
1126
--- local dilated = p:dilate(neighbourhood.moore())
1127
--- ```
1128
---@param ip forma.pattern pattern to dilate
1129
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1130
---@return forma.pattern pattern a new, dilated pattern
1131
function pattern.dilate(ip, nbh)
1✔
1132
    nbh = nbh or neighbourhood.moore()
8✔
1133
    assert(getmetatable(ip) == pattern, "pattern.dilate requires a pattern as the first argument")
8✔
1134
    assert(getmetatable(nbh) == neighbourhood, "pattern.dilate requires a neighbourhood as the second argument")
8✔
1135
    local np = pattern.clone(ip)
8✔
1136
    for x, y in ip:cell_coordinates() do
31✔
1137
        for j = 1, #nbh, 1 do
199✔
1138
            local offset = nbh[j]
176✔
1139
            local nx = x + offset.x
176✔
1140
            local ny = y + offset.y
176✔
1141
            if not np:has_cell(nx, ny) then
176✔
1142
                np:insert(nx, ny)
73✔
1143
            end
1144
        end
1145
    end
1146
    return np
8✔
1147
end
1148

1149
--- Returns the morphological gradient of the pattern.
1150
--- Computes the difference between the dilation and erosion.
1151
---
1152
--- ```lua
1153
--- local grad = p:gradient(neighbourhood.moore())
1154
--- ```
1155
---@param ip forma.pattern pattern to process
1156
---@param nbh? forma.neighbourhood neighbourhood for dilation/erosion (default: neighbourhood.moore())
1157
---@return forma.pattern pattern a new pattern representing the gradient
1158
function pattern.gradient(ip, nbh)
1✔
1159
    nbh = nbh or neighbourhood.moore()
2✔
1160
    assert(getmetatable(ip) == pattern, "pattern.gradient requires a pattern as the first argument")
2✔
1161
    assert(getmetatable(nbh) == neighbourhood, "pattern.gradient requires a neighbourhood as the second argument")
2✔
1162
    return pattern.dilate(ip, nbh) - pattern.erode(ip, nbh)
2✔
1163
end
1164

1165
--- Returns the morphological opening of the pattern.
1166
--- Performs erosion followed by dilation to remove small artifacts.
1167
---
1168
--- ```lua
1169
--- local opened = p:opening(neighbourhood.moore())
1170
--- ```
1171
---@param ip forma.pattern pattern to process
1172
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1173
---@return forma.pattern pattern a new, opened pattern
1174
function pattern.opening(ip, nbh)
1✔
1175
    nbh = nbh or neighbourhood.moore()
1✔
1176
    assert(getmetatable(ip) == pattern, "pattern.opening requires a pattern as the first argument")
1✔
1177
    assert(getmetatable(nbh) == neighbourhood, "pattern.opening requires a neighbourhood as the second argument")
1✔
1178
    local eroded = pattern.erode(ip, nbh)
1✔
1179
    return pattern.dilate(eroded, nbh)
1✔
1180
end
1181

1182
--- Returns the morphological closing of the pattern.
1183
--- Performs dilation followed by erosion to fill small holes.
1184
---
1185
--- ```lua
1186
--- local closed = p:closing(neighbourhood.moore())
1187
--- ```
1188
---@param ip forma.pattern pattern to process
1189
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1190
---@return forma.pattern pattern a new, closed pattern
1191
function pattern.closing(ip, nbh)
1✔
1192
    nbh = nbh or neighbourhood.moore()
1✔
1193
    assert(getmetatable(ip) == pattern, "pattern.closing requires a pattern as the first argument")
1✔
1194
    assert(getmetatable(nbh) == neighbourhood, "pattern.closing requires a neighbourhood as the second argument")
1✔
1195
    local dilated = pattern.dilate(ip, nbh)
1✔
1196
    return pattern.erode(dilated, nbh)
1✔
1197
end
1198

1199
--- Returns a pattern of cells that form the interior hull.
1200
--- These are cells that neighbor inactive cells while still belonging to the pattern.
1201
---
1202
--- ```lua
1203
--- local interior = p:interior_hull(neighbourhood.moore())
1204
--- ```
1205
---@param ip forma.pattern pattern to process
1206
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1207
---@return forma.pattern pattern a new pattern representing the interior hull
1208
function pattern.interior_hull(ip, nbh)
1✔
1209
    nbh = nbh or neighbourhood.moore()
3✔
1210
    assert(getmetatable(ip) == pattern, "pattern.interior_hull requires a pattern as the first argument")
3✔
1211
    assert(getmetatable(nbh) == neighbourhood, "pattern.interior_hull requires a neighbourhood as an argument")
3✔
1212
    return (ip - pattern.erode(ip, nbh))
3✔
1213
end
1214

1215
--- Returns a pattern of cells that form the exterior hull.
1216
--- This consists of inactive neighbours of the pattern, useful for enlarging or
1217
--- determining non-overlapping borders.
1218
---
1219
--- ```lua
1220
--- local exterior = p:exterior_hull(neighbourhood.moore())
1221
--- ```
1222
---@param ip forma.pattern pattern to process
1223
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1224
---@return forma.pattern pattern a new pattern representing the exterior hull
1225
function pattern.exterior_hull(ip, nbh)
1✔
1226
    nbh = nbh or neighbourhood.moore()
3✔
1227
    assert(getmetatable(ip) == pattern, "pattern.exterior_hull requires a pattern as the first argument")
3✔
1228
    assert(getmetatable(nbh) == neighbourhood, "pattern.exterior_hull requires a neighbourhood as an argument")
3✔
1229
    return (pattern.dilate(ip, nbh) - ip)
3✔
1230
end
1231

1232
-- Check if pattern a fits entirely within domain b when shifted by coordshift
1233
local function can_pack_at(a, b, coordshift)
1234
    for acell in a:cells() do
403✔
1235
        local shifted = acell + coordshift
202✔
1236
        if not b:has_cell(shifted.x, shifted.y) then
202✔
1237
            return false
1✔
1238
        end
1239
    end
1240
    return true
201✔
1241
end
1242

1243
--- Finds a packing offset where pattern a fits entirely within domain b.
1244
--- Returns a coordinate shift that, when applied to a, makes it tile inside b.
1245
---
1246
--- ```lua
1247
--- local offset = pattern.find_packing_position(p, domain)
1248
--- ```
1249
---@param a forma.pattern pattern to pack
1250
---@param b forma.pattern domain pattern in which to pack a
1251
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
1252
---@return forma.cell? shift a cell (as a coordinate shift) if a valid position is found; nil otherwise
1253
function pattern.find_packing_position(a, b, rng)
1✔
1254
    assert(getmetatable(a) == pattern, "pattern.find_packing_position requires a pattern as the first argument")
102✔
1255
    assert(getmetatable(b) == pattern, "pattern.find_packing_position requires a pattern as a second argument")
102✔
1256
    assert(a:size() > 0, "pattern.find_packing_position requires a non-empty pattern as the first argument")
102✔
1257
    local hinge = a:rcell(rng)
102✔
1258
    for bcell in b:shuffled_cells(rng) do
103✔
1259
        local coordshift = bcell - hinge
101✔
1260
        if can_pack_at(a, b, coordshift) then
101✔
1261
            return coordshift
100✔
1262
        end
1263
    end
1264
    return nil
2✔
1265
end
1266

1267
--- Finds a center-weighted packing offset to place pattern a within pattern
1268
--- b as close as possible to the cell c. If no `c` is provided, then the centroid
1269
--- of pattern b is used.
1270
---
1271
--- ```lua
1272
--- local central_offset = pattern.find_central_packing_position(p, domain, c)
1273
--- ```
1274
---@param a forma.pattern pattern to pack
1275
---@param b forma.pattern domain pattern
1276
---@param c? forma.cell cell to act as a center for packing
1277
---@return forma.cell? shift a coordinate shift if a valid position is found; nil otherwise
1278
function pattern.find_central_packing_position(a, b, c)
1✔
1279
    assert(getmetatable(a) == pattern, "pattern.find_central_packing_position requires a pattern as the first argument")
103✔
1280
    assert(getmetatable(b) == pattern, "pattern.find_central_packing_position requires a pattern as a second argument")
103✔
1281
    assert(a:size() > 0, "pattern.find_central_packing_position requires a non-empty pattern as the first argument")
103✔
1282
    if b:size() == 0 or a:size() > b:size() then return nil end
103✔
1283
    if c == nil then c = b:centroid() end
101✔
1284
    local hinge    = a:medoid()
101✔
1285
    local allcells = b:cell_list()
101✔
1286
    local function distance_to_c(k, j)
1287
        local adist = (k.x - c.x) * (k.x - c.x) + (k.y - c.y) * (k.y - c.y)
28,626✔
1288
        local bdist = (j.x - c.x) * (j.x - c.x) + (j.y - c.y) * (j.y - c.y)
28,626✔
1289
        return adist < bdist
28,626✔
1290
    end
1291
    table.sort(allcells, distance_to_c)
101✔
1292
    for i = 1, #allcells do
101✔
1293
        local coordshift = allcells[i] - hinge
101✔
1294
        if can_pack_at(a, b, coordshift) then
101✔
1295
            return coordshift
101✔
1296
        end
1297
    end
UNCOV
1298
    return nil
×
1299
end
1300

1301
--- Returns a multipattern of the connected components within the pattern.
1302
--- Uses flood-fill to extract contiguous subpatterns.
1303
---
1304
--- ```lua
1305
--- local components = p:connected_components(neighbourhood.moore())
1306
--- ```
1307
---@param ip forma.pattern pattern to analyze
1308
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.moore())
1309
---@return forma.multipattern multipattern containing each connected component as a subpattern
1310
function pattern.connected_components(ip, nbh)
1✔
1311
    nbh = nbh or neighbourhood.moore()
19✔
1312
    assert(getmetatable(ip) == pattern, "pattern.connected_components requires a pattern as the first argument")
19✔
1313
    assert(getmetatable(nbh) == neighbourhood,
38✔
1314
        "pattern.connected_components requires a neighbourhood as the second argument")
19✔
1315
    local wp = pattern.clone(ip)
19✔
1316
    local mp = multipattern.new()
19✔
1317
    while pattern.size(wp) > 0 do
90✔
1318
        local seed_cell = assert(wp:cells()())
71✔
1319
        local segment = pattern.floodfill(wp, seed_cell, nbh)
71✔
1320
        mp:insert(segment)
71✔
1321
        for x, y in segment:cell_coordinates() do
1,623✔
1322
            wp:remove(x, y)
1,552✔
1323
        end
1324
    end
1325
    return mp
19✔
1326
end
1327

1328
--- Returns a multipattern of the interior holes of the pattern.
1329
--- Interior holes are inactive regions completely surrounded by active cells.
1330
---
1331
--- ```lua
1332
--- local holes = p:interior_holes(neighbourhood.von_neumann())
1333
--- ```
1334
---@param ip forma.pattern pattern to analyze
1335
---@param nbh? forma.neighbourhood neighbourhood (default: neighbourhood.von_neumann())
1336
---@return forma.multipattern multipattern of interior hole subpatterns
1337
function pattern.interior_holes(ip, nbh)
1✔
1338
    nbh = nbh or neighbourhood.von_neumann()
13✔
1339
    assert(getmetatable(ip) == pattern, "pattern.interior_holes requires a pattern as the first argument")
13✔
1340
    assert(ip:size() > 0, "pattern.interior_holes requires a non-empty pattern as the first argument")
13✔
1341
    assert(getmetatable(nbh) == neighbourhood, "pattern.interior_holes requires a neighbourhood as the second argument")
13✔
1342
    local primitives = require('forma.primitives')
13✔
1343
    local size = ip.max - ip.min + cell.new(1, 1)
13✔
1344
    local interior = primitives.square(size.x, size.y):translate(ip.min.x, ip.min.y) - ip
13✔
1345
    local connected_components = pattern.connected_components(interior, nbh)
13✔
1346
    local function fn(sp)
1347
        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✔
1348
            return true
14✔
1349
        end
1350
        return false
44✔
1351
    end
1352
    return connected_components:filter(fn)
13✔
1353
end
1354

1355
--- Partitions the pattern using binary space partitioning (BSP).
1356
--- Recursively subdivides contiguous rectangular areas until each partition's volume is below th_volume.
1357
---
1358
--- ```lua
1359
--- local partitions = p:bsp(50)
1360
--- ```
1361
---@param ip forma.pattern pattern to partition
1362
---@param th_volume number threshold volume for final partitions
1363
---@param alpha? number parameter for squareness of BSP
1364
---@return forma.multipattern multipattern of BSP subpatterns
1365
function pattern.bsp(ip, th_volume, alpha)
1✔
1366
    assert(getmetatable(ip) == pattern, "pattern.bsp requires a pattern as an argument")
2✔
1367
    assert(th_volume, "pattern.bsp rules must specify a threshold volume for partitioning")
2✔
1368
    assert(th_volume > 0, "pattern.bsp rules must specify positive threshold volume for partitioning")
2✔
1369
    local available = ip:clone()
2✔
1370
    local mp = multipattern.new()
2✔
1371
    local bsp = require('forma.utils.bsp')
2✔
1372
    while pattern.size(available) > 0 do
4✔
1373
        local min_rect, max_rect = bsp.max_rectangle_coordinates(available, alpha)
2✔
1374
        if max_rect.x < min_rect.x then break end
2✔
1375
        bsp.split(min_rect, max_rect, th_volume, mp)
2✔
1376
        available = available - mp:union_all()
2✔
1377
    end
1378
    return mp
2✔
1379
end
1380

1381
--- Categorizes cells in the pattern based on neighbourhood configurations.
1382
--- Returns a multipattern with one subpattern per neighbourhood category.
1383
---
1384
--- ```lua
1385
--- local categories = p:neighbourhood_categories(neighbourhood.moore())
1386
--- ```
1387
---@param ip forma.pattern pattern whose cells are to be categorized
1388
---@param nbh forma.neighbourhood neighbourhood used for categorization
1389
---@return forma.multipattern multipattern with each category represented as a subpattern
1390
function pattern.neighbourhood_categories(ip, nbh)
1✔
1391
    assert(getmetatable(ip) == pattern, "pattern.neighbourhood_categories requires a pattern as a first argument")
2✔
1392
    assert(getmetatable(nbh) == neighbourhood,
4✔
1393
        "pattern.neighbourhood_categories requires a neighbourhood as a second argument")
2✔
1394
    local category_patterns = {}
2✔
1395
    for i = 1, nbh:get_ncategories(), 1 do
274✔
1396
        category_patterns[i] = pattern.new()
272✔
1397
    end
1398
    for icell in ip:cells() do
82✔
1399
        local cat = nbh:categorise(ip, icell)
80✔
1400
        category_patterns[cat]:insert(icell.x, icell.y)
80✔
1401
    end
1402
    return multipattern.new(category_patterns)
2✔
1403
end
1404

1405
--- Applies Perlin noise sampling to the pattern.
1406
--- Generates a multipattern by thresholding Perlin noise values at multiple levels.
1407
---
1408
--- ```lua
1409
--- local noise_samples = p:perlin(0.1, 4, {0.3, 0.5, 0.7})
1410
--- ```
1411
---@param ip forma.pattern pattern (domain) to sample from
1412
---@param freq number frequency for Perlin noise
1413
---@param depth integer sampling depth
1414
---@param thresholds number[] table of threshold values (each between 0 and 1)
1415
---@param rng? fun(m: integer): integer random number generator (e.g., math.random)
1416
---@return forma.multipattern multipattern with one component per threshold level
1417
function pattern.perlin(ip, freq, depth, thresholds, rng)
1✔
1418
    if rng == nil then rng = math.random end
1✔
1419
    assert(getmetatable(ip) == pattern, "pattern.perlin requires a pattern as the first argument")
1✔
1420
    assert(type(freq) == "number", "pattern.perlin requires a numerical frequency value.")
1✔
1421
    assert(math.floor(depth) == depth, "pattern.perlin requires an integer sampling depth.")
1✔
1422
    assert(type(thresholds) == "table", "pattern.perlin requires a table of requested thresholds.")
1✔
1423
    for _, th in ipairs(thresholds) do
5✔
1424
        assert(th >= 0 and th <= 1, "pattern.perlin requires thresholds between 0 and 1.")
4✔
1425
    end
1426
    local samples = {}
1✔
1427
    for i = 1, #thresholds, 1 do
5✔
1428
        samples[i] = pattern.new()
4✔
1429
    end
1430
    local noise = require('forma.utils.noise')
1✔
1431
    local p_noise = noise.init(rng)
1✔
1432
    for ix, iy in ip:cell_coordinates() do
1,601✔
1433
        local nv = noise.perlin(p_noise, ix, iy, freq, depth)
1,600✔
1434
        for ith, th in ipairs(thresholds) do
8,000✔
1435
            if nv >= th then
6,400✔
1436
                samples[ith]:insert(ix, iy)
3,059✔
1437
            end
1438
        end
1439
    end
1440
    return multipattern.new(samples)
1✔
1441
end
1442

1443
--- Generates Voronoi tessellation segments for a domain based on seed points.
1444
---
1445
--- ```lua
1446
--- local segments = pattern.voronoi(seeds, domain, cell.euclidean)
1447
--- ```
1448
---@param seeds forma.pattern pattern containing seed cells
1449
---@param domain forma.pattern pattern defining the tessellation domain
1450
---@param measure fun(a: forma.cell, b: forma.cell): number distance function (e.g., cell.euclidean)
1451
---@return forma.multipattern multipattern of Voronoi segments
1452
function pattern.voronoi(seeds, domain, measure)
1✔
1453
    assert(getmetatable(seeds) == pattern, "pattern.voronoi requires a pattern as the first argument")
7✔
1454
    assert(getmetatable(domain) == pattern, "pattern.voronoi requires a pattern as a second argument")
7✔
1455
    assert(pattern.size(seeds) > 0, "pattern.voronoi requires at least one target cell/seed")
7✔
1456
    local seedcells = {}
7✔
1457
    local segments  = {}
7✔
1458
    for iseed in seeds:cells() do
77✔
1459
        assert(domain:has_cell(iseed.x, iseed.y), "forma.voronoi: cell outside of domain")
70✔
1460
        table.insert(seedcells, iseed)
70✔
1461
        table.insert(segments, pattern.new())
70✔
1462
    end
1463
    for dp in domain:cells() do
707✔
1464
        local min_cell = 1
700✔
1465
        local min_dist = measure(dp, seedcells[1])
700✔
1466
        for j = 2, #seedcells, 1 do
7,000✔
1467
            local distance = measure(dp, seedcells[j])
6,300✔
1468
            if distance < min_dist then
6,300✔
1469
                min_cell = j
1,027✔
1470
                min_dist = distance
1,027✔
1471
            end
1472
        end
1473
        segments[min_cell]:insert(dp.x, dp.y)
700✔
1474
    end
1475
    return multipattern.new(segments)
7✔
1476
end
1477

1478
--- Performs centroidal Voronoi tessellation (Lloyd's algorithm) on a set of seeds.
1479
--- Iteratively relaxes seed positions until convergence or a maximum number of iterations.
1480
---
1481
--- ```lua
1482
--- local segments, relaxed_seeds, converged = pattern.voronoi_relax(seeds, domain, cell.euclidean)
1483
--- ```
1484
---@param seeds forma.pattern initial seed pattern
1485
---@param domain forma.pattern tessellation domain pattern
1486
---@param measure fun(a: forma.cell, b: forma.cell): number distance function (e.g., cell.euclidean)
1487
---@param max_ite? integer maximum iterations (default: 30)
1488
---@return forma.multipattern segments a multipattern of Voronoi segments
1489
---@return forma.pattern seeds a pattern of relaxed seed positions
1490
---@return boolean converged whether the algorithm converged
1491
function pattern.voronoi_relax(seeds, domain, measure, max_ite)
1✔
1492
    if max_ite == nil then max_ite = 30 end
1✔
1493
    assert(getmetatable(seeds) == pattern, "pattern.voronoi_relax requires a pattern as the first argument")
1✔
1494
    assert(getmetatable(domain) == pattern, "pattern.voronoi_relax requires a pattern as a second argument")
1✔
1495
    assert(type(measure) == 'function', "pattern.voronoi_relax requires a distance measure as an argument")
1✔
1496
    assert(seeds:size() <= domain:size(), "pattern.voronoi_relax: too many seeds for domain")
1✔
1497
    local current_seeds = seeds:clone()
1✔
1498
    for ite = 1, max_ite, 1 do
4✔
1499
        local tesselation = pattern.voronoi(current_seeds, domain, measure)
4✔
1500
        local next_seeds  = pattern.new()
4✔
1501
        for iseg = 1, tesselation:n_components(), 1 do
44✔
1502
            if tesselation[iseg]:size() > 0 then
40✔
1503
                local cent = tesselation[iseg]:centroid()
40✔
1504
                if domain:has_cell(cent.x, cent.y) then
40✔
1505
                    if not next_seeds:has_cell(cent.x, cent.y) then
40✔
1506
                        next_seeds:insert(cent.x, cent.y)
40✔
1507
                    end
1508
                else
UNCOV
1509
                    local med = tesselation[iseg]:medoid()
×
UNCOV
1510
                    if not next_seeds:has_cell(med.x, med.y) then
×
UNCOV
1511
                        next_seeds:insert(med.x, med.y)
×
1512
                    end
1513
                end
1514
            end
1515
        end
1516
        if current_seeds == next_seeds then
4✔
1517
            return tesselation, current_seeds, true
1✔
1518
        elseif ite == max_ite then
3✔
UNCOV
1519
            return tesselation, current_seeds, false
×
1520
        end
1521
        current_seeds = next_seeds
3✔
1522
    end
NEW
1523
    error("This should not be reachable")
×
1524
end
1525

1526
--- Returns the maximum allowed coordinate for spatial hashing.
1527
---
1528
---@return number max_coordinate maximum coordinate value
1529
function pattern.get_max_coordinate()
1✔
1530
    return MAX_COORDINATE
1✔
1531
end
1532

1533
--- Tests the conversion between (x, y) coordinates and the spatial hash key.
1534
---
1535
---@param x number test x-coordinate
1536
---@param y number test y-coordinate
1537
---@return boolean valid true if the conversion is correct, false otherwise
1538
function pattern.test_coordinate_map(x, y)
1✔
1539
    assert(type(x) == 'number' and type(y) == 'number',
20✔
1540
        "pattern.test_coordinate_map requires two numbers as arguments")
10✔
1541
    local key = coordinates_to_key(x, y)
10✔
1542
    local tx, ty = key_to_coordinates(key)
10✔
1543
    return (x == tx) and (y == ty)
10✔
1544
end
1545

1546
--- Prints the pattern within an optional domain, line-by-line.
1547
---
1548
---@param ip forma.pattern pattern to render
1549
---@param char? string single-character to draw "on" cells
1550
---@param domain? forma.pattern pattern defining the bounding box
1551
---@param printer? fun(line: string) function for printing each line; defaults to io.write(line.."\n")
1552
function pattern.print(ip, char, domain, printer)
1✔
1553
    assert(getmetatable(ip) == pattern, "pattern.print requires a pattern as the first argument")
1✔
1554
    local onchar  = char or ip.onchar
1✔
1555
    local offchar = ip.offchar
1✔
1556
    domain        = domain or ip
1✔
1557
    assert(getmetatable(domain) == pattern, "pattern.print requires a pattern as the domain argument")
1✔
1558

UNCOV
1559
    local print_line = printer
×
1560
        or function(line) io.write(line .. "\n") end
1✔
1561

1562
    for y = domain.min.y, domain.max.y, 1 do
2✔
1563
        local chars = {}
1✔
1564
        for x = domain.min.x, domain.max.x, 1 do
2✔
1565
            chars[#chars + 1] = ip:has_cell(x, y) and onchar or offchar
1✔
1566
        end
1567
        print_line(table.concat(chars))
1✔
1568
    end
1569
end
1570

1571
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