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

nhartland / forma / 23107336182

15 Mar 2026 09:10AM UTC coverage: 98.213% (+0.07%) from 98.145%
23107336182

Pull #31

github

web-flow
Merge b17190fae into a08b919c6
Pull Request #31: Simplify

30 of 30 new or added lines in 1 file covered. (100.0%)

9 existing lines in 1 file now uncovered.

2088 of 2126 relevant lines covered (98.21%)

8587.22 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 procedurally:
16
--      pattern.method(input_pattern, ... )
17
-- or as methods:
18
--      input_pattern:method(... )
19
--
20
-- @usage
21
-- -- Procedural style:
22
-- local p1 = pattern.new()
23
-- pattern.insert(p1, 1, 1)
24
--
25
-- -- Method chaining:
26
-- local p2 = pattern.new():insert(1, 1)
27
--
28
-- -- Prototype style:
29
-- local p3 = pattern.new({{1,1,1}, {1,0,1}, {1,1,1}})
30
--
31
-- -- Retrieve a random cell and the medoid cell:
32
-- local random_cell = p1:rcell()
33
-- local medoid_cell = p1:medoid()
34
--
35
-- -- Compute the exterior hull using the Moore neighbourhood:
36
-- local outer_hull = p1:exterior_hull(neighbourhood.moore())
37
-- -- or equivalently:
38
-- outer_hull = pattern.exterior_hull(p1, neighbourhood.moore())
39
--
40
-- @module forma.pattern
41

42
--@class forma.pattern
43
local pattern = {}
1✔
44

45
local min   = math.min
1✔
46
local max   = math.max
1✔
47
local floor = math.floor
1✔
48

49
local cell          = require('forma.cell')
1✔
50
local neighbourhood = require('forma.neighbourhood')
1✔
51
local rutils        = require('forma.utils.random')
1✔
52
local multipattern  = require('forma.multipattern')
1✔
53

54
-- Pattern indexing.
55
-- Enables the syntax sugar pattern:method.
56
pattern.__index = pattern
1✔
57

58
-- Pattern coordinates (either x or y) must be within ± MAX_COORDINATE.
59
local MAX_COORDINATE  = 65536
1✔
60
local COORDINATE_SPAN = 2 * MAX_COORDINATE + 1
1✔
61

62
--- Generates the cellmap key from coordinates.
63
-- @param x the x-coordinate (number).
64
-- @param y the y-coordinate (number).
65
-- @return a unique key representing the cell.
66
local function coordinates_to_key(x, y)
67
    return (x + MAX_COORDINATE) * COORDINATE_SPAN + (y + MAX_COORDINATE)
1,642,858✔
68
end
69

70
--- Generates the coordinates from the key.
71
-- @param key the spatial hash key (number).
72
-- @return the x and y coordinates (numbers).
73
local function key_to_coordinates(key)
74
    local yp = (key % COORDINATE_SPAN)
214,519✔
75
    local xp = (key - yp) / COORDINATE_SPAN
214,519✔
76
    return xp - MAX_COORDINATE, yp - MAX_COORDINATE
214,519✔
77
end
78

79
--- Basic pattern functions.
80
-- Here are the basic functions for creating and manipulating patterns.
81
-- @section basic
82

83
--- Pattern constructor.
84
-- Returns a new pattern. If a prototype is provided (an N×M table of 1's and 0's),
85
-- the corresponding active cells are inserted.
86
--
87
-- @param prototype (optional) an N×M table of ones and zeros.
88
-- @return a new pattern according to the prototype.
89
function pattern.new(prototype)
1✔
90
    local np = {}
7,319✔
91

92
    np.max = cell.new(-math.huge, -math.huge)
7,319✔
93
    np.min = cell.new(math.huge, math.huge)
7,319✔
94

95
    -- Characters to be used with tostring metamethod.
96
    np.offchar = '0'
7,319✔
97
    np.onchar  = '1'
7,319✔
98

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

102
    np = setmetatable(np, pattern)
7,319✔
103

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

129
    return np
7,319✔
130
end
131

132
--- Creates a copy of an existing pattern.
133
--
134
-- @param ip input pattern to clone.
135
-- @return a new pattern that is a duplicate of ip.
136
function pattern.clone(ip)
1✔
137
    assert(getmetatable(ip) == pattern, "pattern cloning requires a pattern as the first argument")
1,338✔
138
    local newpat = pattern.new()
1,338✔
139

140
    for x, y in ip:cell_coordinates() do
24,369✔
141
        newpat:insert(x, y)
23,031✔
142
    end
143

144
    newpat.offchar = ip.offchar
1,338✔
145
    newpat.onchar  = ip.onchar
1,338✔
146

147
    return newpat
1,338✔
148
end
149

150
--- Inserts a new cell into the pattern.
151
-- Returns the modified pattern to allow for method chaining.
152
--
153
-- @param ip pattern to modify.
154
-- @param x x-coordinate (integer) of the new cell.
155
-- @param y y-coordinate (integer) of the new cell.
156
-- @return the updated pattern (for cascading calls).
157
function pattern.insert(ip, x, y)
1✔
158
    assert(floor(x) == x, 'pattern.insert requires an integer for the x coordinate')
189,172✔
159
    assert(floor(y) == y, 'pattern.insert requires an integer for the y coordinate')
189,172✔
160

161
    local key = coordinates_to_key(x, y)
189,172✔
162
    assert(ip.cellmap[key] == nil, "pattern.insert cannot duplicate cells")
189,172✔
163
    local new_index = #ip.cellkey + 1
189,172✔
164
    ip.cellkey[new_index] = key
189,172✔
165
    ip.cellmap[key] = new_index
189,172✔
166

167
    -- Reset pattern extent.
168
    ip.max.x = max(ip.max.x, x)
189,172✔
169
    ip.max.y = max(ip.max.y, y)
189,172✔
170
    ip.min.x = min(ip.min.x, x)
189,172✔
171
    ip.min.y = min(ip.min.y, y)
189,172✔
172

173
    return ip
189,172✔
174
end
175

176
--- Recalculates the bounding box of the pattern from its cells.
177
local function recalculate_bounding_box(ip)
178
    ip.max.x, ip.max.y = -math.huge, -math.huge
925✔
179
    ip.min.x, ip.min.y = math.huge, math.huge
925✔
180
    for x, y in ip:cell_coordinates() do
80,521✔
181
        ip.max.x = max(ip.max.x, x)
79,596✔
182
        ip.max.y = max(ip.max.y, y)
79,596✔
183
        ip.min.x = min(ip.min.x, x)
79,596✔
184
        ip.min.y = min(ip.min.y, y)
79,596✔
185
    end
186
end
187

188
--- Removes a cell from the pattern.
189
-- Returns the modified pattern to allow for method chaining.
190
--
191
-- @param ip pattern to modify.
192
-- @param x x-coordinate (integer) of the cell to remove.
193
-- @param y y-coordinate (integer) of the cell to remove.
194
-- @return the updated pattern (for cascading calls).
195
function pattern.remove(ip, x, y)
1✔
196
    local key_to_remove = coordinates_to_key(x, y)
2,243✔
197
    local index_to_remove = ip.cellmap[key_to_remove]
2,243✔
198

199
    if index_to_remove then
2,243✔
200
        local last_index = #ip.cellkey
2,242✔
201
        local last_key = ip.cellkey[last_index]
2,242✔
202

203
        -- Swap with last element
204
        ip.cellkey[index_to_remove] = last_key
2,242✔
205
        ip.cellmap[last_key] = index_to_remove
2,242✔
206

207
        -- Pop last element
208
        ip.cellkey[last_index] = nil
2,242✔
209
        ip.cellmap[key_to_remove] = nil
2,242✔
210

211
        -- Recompute bounding box if removed cell was on the boundary
212
        if x == ip.min.x or x == ip.max.x or y == ip.min.y or y == ip.max.y then
2,242✔
213
            recalculate_bounding_box(ip)
925✔
214
        end
215
    end
216

217
    return ip
2,243✔
218
end
219

220
--- Checks if a cell at (x, y) is active in the pattern.
221
--
222
-- @param ip pattern to check.
223
-- @param x x-coordinate (integer).
224
-- @param y y-coordinate (integer).
225
-- @return boolean true if the cell is active, false otherwise.
226
function pattern.has_cell(ip, x, y)
1✔
227
    local key = coordinates_to_key(x, y)
1,451,433✔
228
    return ip.cellmap[key] ~= nil
1,451,433✔
229
end
230

231
--- Filters the pattern using a boolean callback, returning a subpattern.
232
--
233
-- @param ip the original pattern.
234
-- @param fn a function(cell) -> boolean that determines if a cell is kept.
235
-- @return a new pattern containing only the cells that pass the filter.
236
function pattern.filter(ip, fn)
1✔
237
    assert(getmetatable(ip) == pattern, "pattern.filter requires a pattern as the first argument")
1✔
238
    assert(type(fn) == 'function', 'pattern.filter requires a function for the second argument')
1✔
239
    local np = pattern.new()
1✔
240
    for icell in ip:cells() do
101✔
241
        if fn(icell) == true then
100✔
242
            np:insert(icell.x, icell.y)
90✔
243
        end
244
    end
245
    return np
1✔
246
end
247

248
--- Returns the number of active cells in the pattern.
249
--
250
-- @param ip pattern to measure.
251
-- @return integer count of active cells.
252
function pattern.size(ip)
1✔
253
    assert(getmetatable(ip) == pattern, "pattern.size requires a pattern as the first argument")
16,149✔
254
    return #ip.cellkey
16,149✔
255
end
256

257
--- General pattern utilites.
258
-- @section utils
259

260
--- Comparator function to sort patterns by their size (descending).
261
--
262
-- @param pa first pattern.
263
-- @param pb second pattern.
264
-- @return boolean true if pa's size is greater than pb's.
265
function pattern.size_sort(pa, pb)
1✔
266
    return pa:size() > pb:size()
360✔
267
end
268

269
--- Comparator function to sort patterns by their size (ascending).
270
--
271
-- @param pa first pattern.
272
-- @param pb second pattern.
273
-- @return boolean true if pa's size is less than pb's.
274
function pattern.inverse_size_sort(pa, pb)
1✔
UNCOV
275
    return pa:size() < pb:size()
×
276
end
277

278
--- Computes how densely the bounding box is filled.
279
-- Returns zero for an empty pattern.
280
--
281
-- @param ip input pattern
282
-- @return float the fraction of the bounding box that is occupied
283
function pattern.bounding_box_density(ip)
1✔
284
    assert(getmetatable(ip) == pattern,
6✔
285
        "bounding_box_density: first argument must be a forma.pattern")
3✔
286
    if ip:size() == 0 then return 0 end
3✔
287
    local bb_area = (ip.max.x - ip.min.x + 1) * (ip.max.y - ip.min.y + 1)
3✔
288
    return ip:size() / bb_area
3✔
289
end
290

291
--- Computes the asymmetry of the pattern's bounding box.
292
-- Returns zero in the case of an empty pattern.
293
--
294
-- @param ip input pattern
295
-- @return float the ratio of the bounding box's longest to shortest edge
296
function pattern.bounding_box_asymmetry(ip)
1✔
297
    assert(getmetatable(ip) == pattern,
4✔
298
        "bounding_box_asymmetry: first argument must be a forma.pattern")
2✔
299
    if ip:size() == 0 then return 0 end
2✔
UNCOV
300
    return (
×
301
        (max((ip.max.x - ip.min.x), (ip.max.y - ip.min.y)) + 1)
2✔
302
        / (min((ip.max.x - ip.min.x), (ip.max.y - ip.min.y)) + 1)
2✔
303
    )
2✔
304
end
305

306
--- Counts active neighbors around a specified cell within the pattern.
307
-- Can be invoked with either a cell object or with x and y coordinates.
308
--
309
-- @param p a pattern.
310
-- @param nbh a neighbourhood (e.g., neighbourhood.moore()).
311
-- @param arg1 either a cell (with x and y fields) or the x-coordinate (integer).
312
-- @param arg2 (optional) the y-coordinate (integer) if arg1 is not a cell.
313
-- @return integer count of active neighbouring cells.
314
function pattern.count_neighbors(p, nbh, arg1, arg2)
1✔
315
    assert(getmetatable(p) == pattern,
952✔
316
        "count_neighbors: first argument must be a forma.pattern")
476✔
317
    assert(getmetatable(nbh) == neighbourhood,
952✔
318
        "count_neighbors: second argument must be a neighbourhood")
476✔
319

320
    local x, y
321
    if type(arg1) == 'table' and arg1.x and arg1.y then
476✔
UNCOV
322
        x, y = arg1.x, arg1.y
×
323
    else
324
        x, y = arg1, arg2
476✔
325
    end
326

327
    local count = 0
476✔
328
    for i = 1, #nbh, 1 do
3,384✔
329
        local offset = nbh[i]
2,908✔
330
        local nx, ny = x + offset.x, y + offset.y
2,908✔
331
        if p:has_cell(nx, ny) then
2,908✔
332
            count = count + 1
1,578✔
333
        end
334
    end
335
    return count
476✔
336
end
337

338
--- Returns a list (table) of active cells in the pattern.
339
--
340
-- @param ip pattern to list cells from.
341
-- @return table of cell objects.
342
function pattern.cell_list(ip)
1✔
343
    assert(getmetatable(ip) == pattern, "pattern.cell_list requires a pattern as the first argument")
220✔
344
    local newlist = {}
220✔
345
    for icell in ip:cells() do
5,501✔
346
        newlist[#newlist + 1] = icell
5,281✔
347
    end
348
    return newlist
220✔
349
end
350

351
--- Computes the edit distance between two patterns (the total number of differing cells).
352
--
353
-- @param a first pattern.
354
-- @param b second pattern.
355
-- @return integer representing the edit distance.
356
function pattern.edit_distance(a, b)
1✔
357
    assert(getmetatable(a) == pattern, "pattern.edit_distance requires a pattern as the first argument")
5✔
358
    assert(getmetatable(b) == pattern, "pattern.edit_distance requires a pattern as the second argument")
5✔
359
    local common = pattern.intersect(a, b)
5✔
360
    local edit_distance = (a - common):size() + (b - common):size()
5✔
361
    return edit_distance
5✔
362
end
363

364
--- Set operations.
365
-- @section set_ops
366

367
--- Returns the union of a set of patterns.
368
--
369
-- @param ... a table of patterns or a list of pattern arguments.
370
-- @return a new pattern that is the union of the provided patterns.
371
function pattern.union(...)
1✔
372
    local patterns = { ... }
35✔
373
    if #patterns == 1 and type(patterns[1]) == 'table' then
35✔
374
        patterns = patterns[1]
10✔
375
    end
376

377
    if #patterns == 0 then return pattern.new() end
35✔
378
    if #patterns == 1 then return patterns[1]:clone() end
35✔
379

380
    local total = pattern.clone(patterns[1])
35✔
381
    for i = 2, #patterns, 1 do
5,130✔
382
        local v = patterns[i]
5,095✔
383
        assert(getmetatable(v) == pattern, "pattern.union requires a pattern as an argument")
5,095✔
384
        for x, y in v:cell_coordinates() do
45,365✔
385
            if total:has_cell(x, y) == false then
40,270✔
386
                total:insert(x, y)
40,251✔
387
            end
388
        end
389
    end
390
    return total
35✔
391
end
392

393
--- Returns the intersection of multiple patterns (cells common to all).
394
--
395
-- @param ... two or more patterns to intersect.
396
-- @return a new pattern of cells that exist in every input pattern.
397
function pattern.intersect(...)
1✔
398
    local patterns = { ... }
360✔
399
    assert(#patterns > 1, "pattern.intersect requires at least two patterns as arguments")
360✔
400
    table.sort(patterns, pattern.size_sort)
360✔
401
    local domain = patterns[#patterns]
360✔
402
    local inter  = pattern.new()
360✔
403
    for x, y in domain:cell_coordinates() do
8,716✔
404
        local foundCell = true
8,356✔
405
        for i = #patterns - 1, 1, -1 do
15,029✔
406
            local tpattern = patterns[i]
8,356✔
407
            assert(getmetatable(tpattern) == pattern,
16,712✔
408
                "pattern.intersect requires a pattern as an argument")
8,356✔
409
            if not tpattern:has_cell(x, y) then
8,356✔
410
                foundCell = false
1,683✔
411
                break
1,683✔
412
            end
413
        end
414
        if foundCell == true then
8,356✔
415
            inter:insert(x, y)
6,673✔
416
        end
417
    end
418
    return inter
360✔
419
end
420

421
--- Returns the symmetric difference (XOR) of two patterns.
422
-- Cells are included if they exist in either pattern but not in both.
423
--
424
-- @param a first pattern.
425
-- @param b second pattern.
426
-- @return a new pattern representing the symmetric difference.
427
function pattern.xor(a, b)
1✔
428
    assert(getmetatable(a) == pattern, "pattern.xor requires a pattern as the first argument")
3✔
429
    assert(getmetatable(b) == pattern, "pattern.xor requires a pattern as the second argument")
3✔
430
    return (a + b) - (a * b)
3✔
431
end
432

433
--- Iterators
434
-- @section iterators
435

436
--- Iterator over active cells in the pattern.
437
--
438
-- @param ip pattern to iterate over.
439
-- @return iterator that returns each active cell as a cell object.
440
-- @usage
441
-- for cell in p:cells() do
442
--     print(cell.x, cell.y)
443
-- end
444
function pattern.cells(ip)
1✔
445
    assert(getmetatable(ip) == pattern, "pattern.cells requires a pattern as the first argument")
1,336✔
446
    local icell, ncells = 0, ip:size()
1,336✔
447
    return function()
448
        icell = icell + 1
14,558✔
449
        if icell <= ncells then
14,558✔
450
            local ikey = ip.cellkey[icell]
13,294✔
451
            local x, y = key_to_coordinates(ikey)
13,294✔
452
            return cell.new(x, y)
13,294✔
453
        end
454
    end
455
end
456

457
--- Iterator over active cell coordinates (x, y) in the pattern.
458
--
459
-- @param ip pattern to iterate over.
460
-- @return iterator that returns the x and y coordinates of each active cell.
461
-- @usage
462
-- for x, y in p:cell_coordinates() do
463
--     print(x, y)
464
-- end
465
function pattern.cell_coordinates(ip)
1✔
466
    assert(getmetatable(ip) == pattern, "pattern.cell_coordinates requires a pattern as the first argument")
10,236✔
467
    local icell, ncells = 0, ip:size()
10,236✔
468
    return function()
469
        icell = icell + 1
209,786✔
470
        if icell <= ncells then
209,786✔
471
            local ikey = ip.cellkey[icell]
199,550✔
472
            return key_to_coordinates(ikey)
199,550✔
473
        end
474
    end
475
end
476

477
--- Returns an iterator over active cells in randomized order.
478
--
479
-- @param ip pattern to iterate over.
480
-- @param rng (optional) random number generator (e.g., math.random).
481
-- @return iterator that yields each active cell (cell object) in a random order.
482
-- @usage
483
-- for cell in pattern.shuffled_cells(p) do
484
--     print(cell.x, cell.y)
485
-- end
486
function pattern.shuffled_cells(ip, rng)
1✔
487
    assert(getmetatable(ip) == pattern,
206✔
488
        "pattern.shuffled_cells requires a pattern as the first argument")
103✔
489
    if rng == nil then rng = math.random end
103✔
490
    local icell, ncells = 0, ip:size()
103✔
491
    local cellkeys = ip.cellkey
103✔
492
    local skeys = rutils.shuffled_copy(cellkeys, rng)
103✔
493
    return function()
494
        icell = icell + 1
504✔
495
        if icell <= ncells then
504✔
496
            local ikey = skeys[icell]
501✔
497
            local x, y = key_to_coordinates(ikey)
501✔
498
            return cell.new(x, y)
501✔
499
        end
500
    end
501
end
502

503
--- Returns an iterator over active cell coordinates in randomized order.
504
--
505
-- @param ip pattern to iterate over.
506
-- @param rng (optional) random number generator (e.g., math.random).
507
-- @return iterator that yields x and y coordinates in random order.
508
-- @usage
509
-- for x, y in pattern.shuffled_coordinates(p) do
510
--     print(x, y)
511
-- end
512
function pattern.shuffled_coordinates(ip, rng)
1✔
513
    assert(getmetatable(ip) == pattern,
46✔
514
        "pattern.shuffled_coordinates requires a pattern as the first argument")
23✔
515
    if rng == nil then rng = math.random end
23✔
516
    local icell, ncells = 0, ip:size()
23✔
517
    local cellkeys = ip.cellkey
23✔
518
    local skeys = rutils.shuffled_copy(cellkeys, rng)
23✔
519
    return function()
520
        icell = icell + 1
658✔
521
        if icell <= ncells then
658✔
522
            local ikey = skeys[icell]
656✔
523
            return key_to_coordinates(ikey)
656✔
524
        end
525
    end
526
end
527

528
--- Metamethods
529
-- @section metamethods
530

531
--- Renders the pattern as a string.
532
-- Active cells are shown with pattern.onchar and inactive cells with pattern.offchar.
533
--
534
-- @param ip pattern to render.
535
-- @return string representation of the pattern.
536
-- @usage
537
-- print(p)
538
function pattern.__tostring(ip)
1✔
539
    local str = '- pattern origin: ' .. tostring(ip.min) .. '\n'
1✔
540
    for y = ip.min.y, ip.max.y, 1 do
6✔
541
        for x = ip.min.x, ip.max.x, 1 do
30✔
542
            local char = ip:has_cell(x, y) and ip.onchar or ip.offchar
25✔
543
            str = str .. char
25✔
544
        end
545
        str = str .. '\n'
5✔
546
    end
547
    return str
1✔
548
end
549

550
--- Adds two patterns using the '+' operator (i.e. returns their union).
551
--
552
-- @param a first pattern.
553
-- @param b second pattern.
554
-- @return a new pattern representing the union of a and b.
555
-- @usage
556
-- local combined = p1 + p2
557
function pattern.__add(a, b)
1✔
558
    assert(getmetatable(a) == pattern, "pattern addition requires a pattern as the first argument")
23✔
559
    assert(getmetatable(b) == pattern, "pattern addition requires a pattern as the second argument")
23✔
560
    return pattern.union(a, b)
23✔
561
end
562

563
--- Subtracts one pattern from another using the '-' operator.
564
-- Returns a new pattern with cells in a that are not in b.
565
--
566
-- @param a base pattern.
567
-- @param b pattern to subtract from a.
568
-- @return a new pattern with the difference.
569
-- @usage
570
-- local diff = p1 - p2
571
function pattern.__sub(a, b)
1✔
572
    assert(getmetatable(a) == pattern, "pattern subtraction requires a pattern as the first argument")
39✔
573
    assert(getmetatable(b) == pattern, "pattern subtraction requires a pattern as the second argument")
39✔
574
    local c = pattern.new()
39✔
575
    for x, y in a:cell_coordinates() do
12,351✔
576
        if b:has_cell(x, y) == false then
12,312✔
577
            c:insert(x, y)
1,750✔
578
        end
579
    end
580
    return c
39✔
581
end
582

583
--- Computes the intersection of two patterns using the '*' operator.
584
--
585
-- @param a first pattern.
586
-- @param b second pattern.
587
-- @return a new pattern containing only the cells common to both.
588
-- @usage
589
-- local common = p1 * p2
590
function pattern.__mul(a, b)
1✔
591
    assert(getmetatable(a) == pattern, "pattern multiplication requires a pattern as the first argument")
4✔
592
    assert(getmetatable(b) == pattern, "pattern multiplication requires a pattern as the second argument")
4✔
593
    return pattern.intersect(a, b)
4✔
594
end
595

596
--- Computes the symmetric difference (XOR) of two patterns using the '^' operator.
597
--
598
-- @param a first pattern.
599
-- @param b second pattern.
600
-- @return a new pattern with cells present in either a or b, but not both.
601
-- @usage
602
-- local xor_pattern = p1 ^ p2
603
function pattern.__pow(a, b)
1✔
604
    assert(getmetatable(a) == pattern, "pattern exponent (XOR) requires a pattern as the first argument")
1✔
605
    assert(getmetatable(b) == pattern, "pattern exponent (XOR) requires a pattern as the second argument")
1✔
606
    return pattern.xor(a, b)
1✔
607
end
608

609
--- Tests whether two patterns are identical.
610
--
611
-- @param a first pattern.
612
-- @param b second pattern.
613
-- @return boolean true if the patterns are equal, false otherwise.
614
-- @usage
615
-- if p1 == p2 then
616
--     -- patterns are identical
617
-- end
618
function pattern.__eq(a, b)
1✔
619
    assert(getmetatable(a) == pattern, "pattern equality test requires a pattern as the first argument")
371✔
620
    assert(getmetatable(b) == pattern, "pattern equality test requires a pattern as the second argument")
371✔
621
    if a:size() ~= b:size() then return false end
371✔
622
    if a.min ~= b.min then return false end
366✔
623
    if a.max ~= b.max then return false end
358✔
624
    for i = 1, #a.cellkey do
46,556✔
625
        if b.cellmap[a.cellkey[i]] == nil then
46,199✔
626
            return false
1✔
627
        end
628
    end
629
    return true
357✔
630
end
631

632
--- Cell accessors
633
-- @section cell_accessors
634

635
--- Computes the centroid (arithmetic mean) of all cells in the pattern.
636
-- The result is rounded to the nearest integer coordinate.
637
--
638
-- @param ip pattern to process.
639
-- @return a cell representing the centroid (which may not be active).
640
-- @usage
641
-- local center = p:centroid()
642
function pattern.centroid(ip)
1✔
643
    assert(getmetatable(ip) == pattern, "pattern.centroid requires a pattern as the first argument")
147✔
644
    assert(ip:size() > 0, 'pattern.centroid requires a filled pattern!')
147✔
645
    local sumx, sumy = 0, 0
147✔
646
    for x, y in ip:cell_coordinates() do
5,622✔
647
        sumx = sumx + x
5,475✔
648
        sumy = sumy + y
5,475✔
649
    end
650
    local n = ip:size()
147✔
651
    local intx = floor(sumx / n + 0.5)
147✔
652
    local inty = floor(sumy / n + 0.5)
147✔
653
    return cell.new(intx, inty)
147✔
654
end
655

656
--- Computes the medoid cell of the pattern.
657
-- The medoid minimizes the total distance to all other cells (using Euclidean distance by default).
658
--
659
-- @param ip pattern to process.
660
-- @param measure (optional) distance function (default: Euclidean).
661
-- @return the medoid cell of the pattern.
662
-- @usage
663
-- local medoid = p:medoid()
664
function pattern.medoid(ip, measure)
1✔
665
    assert(getmetatable(ip) == pattern, "pattern.medoid requires a pattern as the first argument")
117✔
666
    assert(ip:size() > 0, 'pattern.medoid requires a filled pattern!')
117✔
667
    measure = measure or cell.euclidean2
117✔
668
    local ncells = ip:size()
117✔
669
    local cell_list = ip:cell_list()
117✔
670
    local distance = {}
117✔
671
    for _ = 1, ncells, 1 do distance[#distance + 1] = 0 end
208✔
672
    local minimal_distance = math.huge
117✔
673
    local minimal_index = -1
117✔
674
    for i = 1, ncells, 1 do
325✔
675
        for j = i, ncells, 1 do
1,182✔
676
            local ij_distance = measure(cell_list[i], cell_list[j])
974✔
677
            distance[i] = distance[i] + ij_distance
974✔
678
            distance[j] = distance[j] + ij_distance
974✔
679
        end
680
        if distance[i] < minimal_distance then
208✔
681
            minimal_index = i
138✔
682
            minimal_distance = distance[i]
138✔
683
        end
684
    end
685
    return cell_list[minimal_index]
117✔
686
end
687

688
--- Returns a random cell from the pattern.
689
--
690
-- @param ip pattern to sample from.
691
-- @param rng (optional) random number generator (e.g., math.random).
692
-- @return a random cell from the pattern.
693
-- @usage
694
-- local random_cell = p:rcell()
695
function pattern.rcell(ip, rng)
1✔
696
    assert(getmetatable(ip) == pattern, "pattern.rcell requires a pattern as the first argument")
508✔
697
    assert(ip:size() > 0, 'pattern.rcell requires a filled pattern!')
508✔
698
    if rng == nil then rng = math.random end
508✔
699
    local icell = rng(#ip.cellkey)
508✔
700
    local ikey = ip.cellkey[icell]
508✔
701
    local x, y = key_to_coordinates(ikey)
508✔
702
    return cell.new(x, y)
508✔
703
end
704

705
--- Transformations
706
-- @section transformations
707

708
--- Returns a new pattern translated by a vector (sx, sy).
709
--
710
-- @param ip pattern to translate.
711
-- @param sx translation along the x-axis (integer).
712
-- @param sy translation along the y-axis (integer).
713
-- @return a new pattern shifted by (sx, sy).
714
-- @usage
715
-- local p_translated = p:translate(2, 3)
716
function pattern.translate(ip, sx, sy)
1✔
717
    assert(getmetatable(ip) == pattern, "pattern.translate requires a pattern as the first argument")
1,304✔
718
    assert(floor(sx) == sx, 'pattern.translate requires an integer for the x coordinate')
1,304✔
719
    assert(floor(sy) == sy, 'pattern.translate requires an integer for the y coordinate')
1,304✔
720
    local sp = pattern.new()
1,304✔
721
    for tx, ty in ip:cell_coordinates() do
13,619✔
722
        local nx = tx + sx
12,315✔
723
        local ny = ty + sy
12,315✔
724
        sp:insert(nx, ny)
12,315✔
725
    end
726
    return sp
1,304✔
727
end
728

729
--- Normalizes the pattern by translating it so that its minimum coordinate is (0,0).
730
--
731
-- @param ip pattern to normalize.
732
-- @return a new normalized pattern.
733
-- @usage
734
-- local p_norm = p:normalise()
735
function pattern.normalise(ip)
1✔
736
    assert(getmetatable(ip) == pattern, "pattern.normalise requires a pattern as the first argument")
4✔
737
    return ip:translate(-ip.min.x, -ip.min.y)
4✔
738
end
739

740
--- Returns an enlarged version of the pattern.
741
-- Each active cell is replaced by an f×f block.
742
--
743
-- @param ip pattern to enlarge.
744
-- @param f enlargement factor (number).
745
-- @return a new enlarged pattern.
746
-- @usage
747
-- local p_big = p:enlarge(2)
748
function pattern.enlarge(ip, f)
1✔
749
    assert(getmetatable(ip) == pattern, "pattern.enlarge requires a pattern as the first argument")
4✔
750
    assert(type(f) == 'number', 'pattern.enlarge requires a number as the enlargement factor')
4✔
751
    local ep = pattern.new()
4✔
752
    for icell in ip:cells() do
34✔
753
        local sv = cell.new(f * icell.x, f * icell.y)
30✔
754
        for i = 0, f - 1, 1 do
90✔
755
            for j = 0, f - 1, 1 do
180✔
756
                ep:insert(sv.x + i, sv.y + j)
120✔
757
            end
758
        end
759
    end
760
    return ep
4✔
761
end
762

763
--- Returns a new pattern rotated 90° clockwise about the origin.
764
--
765
-- @param ip pattern to rotate.
766
-- @return a rotated pattern.
767
-- @usage
768
-- local p_rotated = p:rotate()
769
function pattern.rotate(ip)
1✔
770
    assert(getmetatable(ip) == pattern, "pattern.rotate requires a pattern as the first argument")
6✔
771
    local np = pattern.new()
6✔
772
    for x, y in ip:cell_coordinates() do
46✔
773
        np:insert(y, -x)
40✔
774
    end
775
    return np
6✔
776
end
777

778
--- Returns a new pattern that is a vertical reflection of the original.
779
--
780
-- @param ip pattern to reflect vertically.
781
-- @return a vertically reflected pattern.
782
-- @usage
783
-- local p_vreflected = p:vreflect()
784
function pattern.vreflect(ip)
1✔
785
    assert(getmetatable(ip) == pattern, "pattern.vreflect requires a pattern as the first argument")
2✔
786
    local np = pattern.new()
2✔
787
    for vx, vy in ip:cell_coordinates() do
6✔
788
        local new_y = ip.min.y + ip.max.y - vy
4✔
789
        np:insert(vx, new_y)
4✔
790
    end
791
    return np
2✔
792
end
793

794
--- Returns a new pattern that is a horizontal reflection of the original.
795
--
796
-- @param ip pattern to reflect horizontally.
797
-- @return a horizontally reflected pattern.
798
-- @usage
799
-- local p_hreflected = p:hreflect()
800
function pattern.hreflect(ip)
1✔
801
    assert(getmetatable(ip) == pattern, "pattern.hreflect requires a pattern as the first argument")
2✔
802
    local np = pattern.new()
2✔
803
    for vx, vy in ip:cell_coordinates() do
6✔
804
        local new_x = ip.min.x + ip.max.x - vx
4✔
805
        np:insert(new_x, vy)
4✔
806
    end
807
    return np
2✔
808
end
809

810
--- Random subpatterns
811
-- @section subpatterns
812

813
--- Returns a random subpattern containing a fixed number of cells.
814
--
815
-- @param ip pattern (domain) to sample from.
816
-- @param ncells number of cells to sample (integer).
817
-- @param rng (optional) random number generator (e.g., math.random).
818
-- @return a new pattern with ncells randomly selected cells.
819
-- @usage
820
-- local sample = p:sample(10)
821
function pattern.sample(ip, ncells, rng)
1✔
822
    assert(getmetatable(ip) == pattern, "pattern.sample requires a pattern as the first argument")
18✔
823
    assert(type(ncells) == 'number', "pattern.sample requires an integer number of cells as the second argument")
18✔
824
    assert(math.floor(ncells) == ncells, "pattern.sample requires an integer number of cells as the second argument")
18✔
825
    assert(ncells > 0, "pattern.sample requires at least one sample to be requested")
18✔
826
    assert(ncells <= ip:size(), "pattern.sample requires a domain larger than the number of requested samples")
18✔
827
    if rng == nil then rng = math.random end
18✔
828
    local p = pattern.new()
18✔
829
    local next_coords = ip:shuffled_coordinates(rng)
18✔
830
    for _ = 1, ncells, 1 do
228✔
831
        local x, y = next_coords()
210✔
832
        p:insert(x, y)
210✔
833
    end
834
    return p
18✔
835
end
836

837
--- Returns a Poisson-disc sampled subpattern.
838
-- Ensures that no two sampled cells are closer than the given radius.
839
--
840
-- @param ip pattern (domain) to sample from.
841
-- @param distance distance function (e.g., cell.euclidean).
842
-- @param radius minimum separation (number).
843
-- @param rng (optional) random number generator (e.g., math.random).
844
-- @return a new pattern sampled with Poisson-disc criteria.
845
-- @usage
846
-- local poisson_sample = p:sample_poisson(cell.euclidean, 5)
847
function pattern.sample_poisson(ip, distance, radius, rng)
1✔
848
    assert(getmetatable(ip) == pattern, "pattern.sample_poisson requires a pattern as the first argument")
1✔
849
    assert(type(distance) == 'function', "pattern.sample_poisson requires a distance measure as an argument")
1✔
850
    assert(type(radius) == "number", "pattern.sample_poisson requires a number as the target radius")
1✔
851
    if rng == nil then rng = math.random end
1✔
852
    local sample = pattern.new()
1✔
853
    local domain = ip:clone()
1✔
854
    while domain:size() > 0 do
10✔
855
        local dart = domain:rcell(rng)
9✔
856
        sample:insert(dart.x, dart.y)
9✔
857
        local to_remove = {}
9✔
858
        for cell_to_check in domain:cells() do
373✔
859
            if distance(cell_to_check, dart) < radius then
364✔
860
                table.insert(to_remove, cell_to_check)
100✔
861
            end
862
        end
863
        for _, cell_to_remove in ipairs(to_remove) do
109✔
864
            domain:remove(cell_to_remove.x, cell_to_remove.y)
100✔
865
        end
866
    end
867
    return sample
1✔
868
end
869

870
--- Returns an approximate Poisson-disc sample using Mitchell's best candidate algorithm.
871
--
872
-- @param ip the input pattern to sample from.
873
-- @param distance distance function (e.g., cell.euclidean).
874
-- @param n number of samples (integer).
875
-- @param k number of candidate attempts per iteration (integer).
876
-- @param rng (optional) random number generator (e.g., math.random).
877
-- @return a new pattern with n samples chosen via the algorithm.
878
-- @usage
879
-- local mitchell_sample = p:sample_mitchell(cell.euclidean, 10, 5)
880
function pattern.sample_mitchell(ip, distance, n, k, rng)
1✔
881
    assert(getmetatable(ip) == pattern,
2✔
882
        "pattern.sample_mitchell requires a pattern as the first argument")
1✔
883
    assert(ip:size() >= n,
2✔
884
        "pattern.sample_mitchell requires a pattern with at least as many points as in the requested sample")
1✔
885
    assert(type(distance) == 'function', "pattern.sample_mitchell requires a distance measure as an argument")
1✔
886
    assert(type(n) == "number", "pattern.sample_mitchell requires a target number of samples")
1✔
887
    assert(type(k) == "number", "pattern.sample_mitchell requires a target number of candidate tries")
1✔
888
    if rng == nil then rng = math.random end
1✔
889
    local seed = ip:rcell(rng)
1✔
890
    local sample = pattern.new():insert(seed.x, seed.y)
1✔
891
    for _ = 2, n, 1 do
10✔
892
        local min_distance = 0
9✔
893
        local min_sample = nil
9✔
894
        for _ = 1, k, 1 do
99✔
895
            local jcell = ip:rcell(rng)
90✔
896
            while sample:has_cell(jcell.x, jcell.y) do
95✔
897
                jcell = ip:rcell(rng)
5✔
898
            end
899
            local jdistance = math.huge
90✔
900
            for vcell in sample:cells() do
540✔
901
                jdistance = math.min(jdistance, distance(jcell, vcell))
450✔
902
            end
903
            if jdistance > min_distance then
90✔
904
                min_sample = jcell
17✔
905
                min_distance = jdistance
17✔
906
            end
907
        end
908
        if min_sample then
9✔
909
            sample:insert(min_sample.x, min_sample.y)
9✔
910
        end
911
    end
912
    return sample
1✔
913
end
914

915
--- Deterministic subpatterns
916
-- @section det_subpatterns
917

918
--- Returns the contiguous subpattern (connected component) starting from a given location.
919
local function floodfill(x, y, nbh, domain, retpat)
920
    local q = { x, y }
727✔
921
    local head = 1
727✔
922
    local tail = 2
727✔
923
    retpat:insert(x, y)
727✔
924

925
    while head < tail do
21,281✔
926
        local cx, cy = q[head], q[head + 1]
20,554✔
927
        head = head + 2
20,554✔
928

929
        for i = 1, #nbh do
175,386✔
930
            local nx = nbh[i].x + cx
154,832✔
931
            local ny = nbh[i].y + cy
154,832✔
932
            if domain:has_cell(nx, ny) and not retpat:has_cell(nx, ny) then
154,832✔
933
                retpat:insert(nx, ny)
19,827✔
934
                q[tail + 1] = nx
19,827✔
935
                q[tail + 2] = ny
19,827✔
936
                tail = tail + 2
19,827✔
937
            end
938
        end
939
    end
940
end
941

942
--- Returns the contiguous subpattern (connected component) starting from a given cell.
943
--
944
-- @param ip pattern upon which the flood fill is to be performed.
945
-- @param icell a cell specifying the origin of the flood fill.
946
-- @param nbh (optional) neighbourhood to use (default: neighbourhood.moore()).
947
-- @return a new pattern containing the connected component.
948
-- @usage
949
-- local component = p:floodfill(cell.new(2, 3))
950
function pattern.floodfill(ip, icell, nbh)
1✔
951
    assert(getmetatable(ip) == pattern, "pattern.floodfill requires a pattern as the first argument")
727✔
952
    assert(icell, "pattern.floodfill requires a cell as the second argument")
727✔
953
    if nbh == nil then nbh = neighbourhood.moore() end
727✔
954
    local retpat = pattern.new()
727✔
955
    if ip:has_cell(icell.x, icell.y) then
727✔
956
        floodfill(icell.x, icell.y, nbh, ip, retpat)
727✔
957
    end
958
    return retpat
727✔
959
end
960

961
--- Finds the largest contiguous rectangular subpattern within the pattern.
962
--
963
-- @param ip pattern to analyze.
964
-- @param alpha 'squareness' parameter. 0 for max rectangle, 1 for max square.
965
-- @return a subpattern representing the maximal rectangle.
966
-- @usage
967
-- local rect = p:max_rectangle()
968
function pattern.max_rectangle(ip, alpha)
1✔
969
    assert(getmetatable(ip) == pattern, "pattern.max_rectangle requires a pattern as an argument")
2✔
970
    local primitives = require('forma.primitives')
2✔
971
    local bsp = require('forma.utils.bsp')
2✔
972
    local min_rect, max_rect = bsp.max_rectangle_coordinates(ip, alpha)
2✔
973
    local size = max_rect - min_rect + cell.new(1, 1)
2✔
974
    return primitives.square(size.x, size.y):translate(min_rect.x, min_rect.y)
2✔
975
end
976

977
--- Computes the convex hull of the pattern.
978
-- The hull points are connected using line rasterization.
979
--
980
-- @param ip pattern to process.
981
-- @return a new pattern representing the convex hull.
982
-- @usage
983
-- local hull = p:convex_hull()
984
function pattern.convex_hull(ip)
1✔
985
    assert(getmetatable(ip) == pattern, "pattern.convex_hull requires a pattern as the first argument")
1✔
986
    assert(ip:size() > 0, "pattern.convex_hull: input pattern must have at least one cell")
1✔
987
    local convex_hull = require('forma.utils.convex_hull')
1✔
988
    local primitives = require('forma.primitives')
1✔
989
    local hull_points = convex_hull.points(ip)
1✔
990
    local chull = pattern.new()
1✔
991
    local function add_line(p1, p2)
992
        local line = primitives.line(p1, p2)
4✔
993
        for x, y in line:cell_coordinates() do
24✔
994
            if not chull:has_cell(x, y) then
20✔
995
                chull:insert(x, y)
16✔
996
            end
997
        end
998
    end
999
    for i = 1, #hull_points - 1, 1 do
4✔
1000
        add_line(hull_points[i], hull_points[i + 1])
3✔
1001
    end
1002
    add_line(hull_points[#hull_points], hull_points[1])
1✔
1003
    return chull
1✔
1004
end
1005

1006
--- Returns a thinned (skeletonized) version of the pattern.
1007
-- Iteratively removes border cells whose Moore neighbours remain a single
1008
-- connected component under `nbh`.
1009
-- @param ip pattern to thin.
1010
-- @param nbh neighbourhood for connectivity; must be a radius-1 neighbourhood
1011
--            (e.g. `neighbourhood.moore()` or `neighbourhood.von_neumann()`).
1012
--            Default: `neighbourhood.moore()`.
1013
-- @return a new, thinned pattern.
1014
-- @usage
1015
-- local thin_p = p:thin()
1016
-- local thin_4 = p:thin(neighbourhood.von_neumann())
1017
function pattern.thin(ip, nbh)
1✔
1018
    assert(getmetatable(ip) == pattern,
8✔
1019
        "pattern.thin requires a pattern as the first argument")
4✔
1020
    nbh = nbh or neighbourhood.moore()
4✔
1021
    assert(getmetatable(nbh) == neighbourhood,
8✔
1022
        "pattern.thin requires a neighbourhood as the second argument")
4✔
1023

1024
    -- Moore neighbourhood used for border detection and local pruning
1025
    local moore = neighbourhood.moore()
4✔
1026
    -- Each iteration does one sub-pass per cardinal direction, only removing
1027
    -- cells exposed on that side.
1028
    local von_neumann = neighbourhood.von_neumann()
4✔
1029

1030
    local p = ip:clone()
4✔
1031
    local function can_delete(x, y, border_cell)
1032
        -- Cell must be exposed on the border side
1033
        if p:has_cell(x + border_cell.x, y + border_cell.y) then return false end
3,769✔
1034
        -- Don't delete endpoints
1035
        if pattern.count_neighbors(p, nbh, x, y) < 2 then return false end
476✔
1036
        -- All Moore neighbours must form a single nbh-connected component
1037
        local nbrs = pattern.new()
445✔
1038
        local seed = nil
445✔
1039
        for i = 1, #moore do
4,005✔
1040
            if p:has_cell(x + moore[i].x, y + moore[i].y) then
3,560✔
1041
                nbrs:insert(moore[i].x, moore[i].y)
1,889✔
1042
                seed = seed or cell.new(moore[i].x, moore[i].y)
1,889✔
1043
            end
1044
        end
1045
        return pattern.floodfill(nbrs, seed, nbh):size() == nbrs:size()
445✔
1046
    end
1047
    local changed = true
4✔
1048
    while changed do
19✔
1049
        changed = false
15✔
1050
        for _, border_cell in ipairs(von_neumann) do
75✔
1051
            local to_remove = {}
60✔
1052
            for x, y in p:cell_coordinates() do
3,829✔
1053
                if can_delete(x, y, border_cell) then
3,769✔
1054
                    to_remove[#to_remove + 1] = { x, y }
385✔
1055
                end
1056
            end
1057
            for _, xy in ipairs(to_remove) do
445✔
1058
                p:remove(xy[1], xy[2])
385✔
1059
                changed = true
385✔
1060
            end
1061
        end
1062
    end
1063

1064
    return p
4✔
1065
end
1066

1067
--- Morphological operations
1068
-- @section morphological
1069

1070
--- Returns the erosion of the pattern.
1071
-- A cell is retained only if all of its neighbours (as defined by nbh) are active.
1072
--
1073
-- @param ip pattern to erode.
1074
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1075
-- @return a new, eroded pattern.
1076
-- @usage
1077
-- local eroded = p:erode(neighbourhood.moore())
1078
function pattern.erode(ip, nbh)
1✔
1079
    nbh = nbh or neighbourhood.moore()
8✔
1080
    assert(getmetatable(ip) == pattern, "pattern.erode requires a pattern as the first argument")
8✔
1081
    assert(getmetatable(nbh) == neighbourhood, "pattern.erode requires a neighbourhood as the second argument")
8✔
1082
    local result = pattern.new()
8✔
1083
    for x, y in ip:cell_coordinates() do
105✔
1084
        local keep = true
97✔
1085
        for j = 1, #nbh, 1 do
300✔
1086
            local offset = nbh[j]
287✔
1087
            local nx = x + offset.x
287✔
1088
            local ny = y + offset.y
287✔
1089
            if not ip:has_cell(nx, ny) then
287✔
1090
                keep = false
84✔
1091
                break
84✔
1092
            end
1093
        end
1094
        if keep then
97✔
1095
            result:insert(x, y)
13✔
1096
        end
1097
    end
1098
    return result
8✔
1099
end
1100

1101
--- Returns the dilation of the pattern.
1102
-- Each active cell contributes its neighbours (as defined by nbh) to the result.
1103
--
1104
-- @param ip pattern to dilate.
1105
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1106
-- @return a new, dilated pattern.
1107
-- @usage
1108
-- local dilated = p:dilate(neighbourhood.moore())
1109
function pattern.dilate(ip, nbh)
1✔
1110
    nbh = nbh or neighbourhood.moore()
8✔
1111
    assert(getmetatable(ip) == pattern, "pattern.dilate requires a pattern as the first argument")
8✔
1112
    assert(getmetatable(nbh) == neighbourhood, "pattern.dilate requires a neighbourhood as the second argument")
8✔
1113
    local np = pattern.clone(ip)
8✔
1114
    for x, y in ip:cell_coordinates() do
31✔
1115
        for j = 1, #nbh, 1 do
199✔
1116
            local offset = nbh[j]
176✔
1117
            local nx = x + offset.x
176✔
1118
            local ny = y + offset.y
176✔
1119
            if not np:has_cell(nx, ny) then
176✔
1120
                np:insert(nx, ny)
73✔
1121
            end
1122
        end
1123
    end
1124
    return np
8✔
1125
end
1126

1127
--- Returns the morphological gradient of the pattern.
1128
-- Computes the difference between the dilation and erosion.
1129
--
1130
-- @param ip pattern to process.
1131
-- @param nbh neighbourhood for dilation/erosion (default: neighbourhood.moore()).
1132
-- @return a new pattern representing the gradient.
1133
-- @usage
1134
-- local grad = p:gradient(neighbourhood.moore())
1135
function pattern.gradient(ip, nbh)
1✔
1136
    nbh = nbh or neighbourhood.moore()
2✔
1137
    assert(getmetatable(ip) == pattern, "pattern.gradient requires a pattern as the first argument")
2✔
1138
    assert(getmetatable(nbh) == neighbourhood, "pattern.gradient requires a neighbourhood as the second argument")
2✔
1139
    return pattern.dilate(ip, nbh) - pattern.erode(ip, nbh)
2✔
1140
end
1141

1142
--- Returns the morphological opening of the pattern.
1143
-- Performs erosion followed by dilation to remove small artifacts.
1144
--
1145
-- @param ip pattern to process.
1146
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1147
-- @return a new, opened pattern.
1148
-- @usage
1149
-- local opened = p:opening(neighbourhood.moore())
1150
function pattern.opening(ip, nbh)
1✔
1151
    nbh = nbh or neighbourhood.moore()
1✔
1152
    assert(getmetatable(ip) == pattern, "pattern.opening requires a pattern as the first argument")
1✔
1153
    assert(getmetatable(nbh) == neighbourhood, "pattern.opening requires a neighbourhood as the second argument")
1✔
1154
    local eroded = pattern.erode(ip, nbh)
1✔
1155
    return pattern.dilate(eroded, nbh)
1✔
1156
end
1157

1158
--- Returns the morphological closing of the pattern.
1159
-- Performs dilation followed by erosion to fill small holes.
1160
--
1161
-- @param ip pattern to process.
1162
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1163
-- @return a new, closed pattern.
1164
-- @usage
1165
-- local closed = p:closing(neighbourhood.moore())
1166
function pattern.closing(ip, nbh)
1✔
1167
    nbh = nbh or neighbourhood.moore()
1✔
1168
    assert(getmetatable(ip) == pattern, "pattern.closing requires a pattern as the first argument")
1✔
1169
    assert(getmetatable(nbh) == neighbourhood, "pattern.closing requires a neighbourhood as the second argument")
1✔
1170
    local dilated = pattern.dilate(ip, nbh)
1✔
1171
    return pattern.erode(dilated, nbh)
1✔
1172
end
1173

1174
--- Returns a pattern of cells that form the interior hull.
1175
-- These are cells that neighbor inactive cells while still belonging to the pattern.
1176
--
1177
-- @param ip pattern to process.
1178
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1179
-- @return a new pattern representing the interior hull.
1180
-- @usage
1181
-- local interior = p:interior_hull(neighbourhood.moore())
1182
function pattern.interior_hull(ip, nbh)
1✔
1183
    nbh = nbh or neighbourhood.moore()
3✔
1184
    assert(getmetatable(ip) == pattern, "pattern.interior_hull requires a pattern as the first argument")
3✔
1185
    assert(getmetatable(nbh) == neighbourhood, "pattern.interior_hull requires a neighbourhood as an argument")
3✔
1186
    return (ip - pattern.erode(ip, nbh))
3✔
1187
end
1188

1189
--- Returns a pattern of cells that form the exterior hull.
1190
-- This consists of inactive neighbours of the pattern, useful for enlarging or
1191
-- determining non-overlapping borders.
1192
--
1193
-- @param ip pattern to process.
1194
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1195
-- @return a new pattern representing the exterior hull.
1196
-- @usage
1197
-- local exterior = p:exterior_hull(neighbourhood.moore())
1198
function pattern.exterior_hull(ip, nbh)
1✔
1199
    nbh = nbh or neighbourhood.moore()
3✔
1200
    assert(getmetatable(ip) == pattern, "pattern.exterior_hull requires a pattern as the first argument")
3✔
1201
    assert(getmetatable(nbh) == neighbourhood, "pattern.exterior_hull requires a neighbourhood as an argument")
3✔
1202
    return (pattern.dilate(ip, nbh) - ip)
3✔
1203
end
1204

1205
--- Packing methods
1206
-- @section packing
1207

1208
-- Check if pattern a fits entirely within domain b when shifted by coordshift
1209
local function can_pack_at(a, b, coordshift)
1210
    for acell in a:cells() do
403✔
1211
        local shifted = acell + coordshift
202✔
1212
        if not b:has_cell(shifted.x, shifted.y) then
202✔
1213
            return false
1✔
1214
        end
1215
    end
1216
    return true
201✔
1217
end
1218

1219
--- Finds a packing offset where pattern a fits entirely within domain b.
1220
-- Returns a coordinate shift that, when applied to a, makes it tile inside b.
1221
--
1222
-- @param a pattern to pack.
1223
-- @param b domain pattern in which to pack a.
1224
-- @param rng (optional) random number generator (e.g., math.random).
1225
-- @return a cell (as a coordinate shift) if a valid position is found; nil otherwise.
1226
-- @usage
1227
-- local offset = pattern.find_packing_position(p, domain)
1228
function pattern.find_packing_position(a, b, rng)
1✔
1229
    assert(getmetatable(a) == pattern, "pattern.find_packing_position requires a pattern as the first argument")
102✔
1230
    assert(getmetatable(b) == pattern, "pattern.find_packing_position requires a pattern as a second argument")
102✔
1231
    assert(a:size() > 0, "pattern.find_packing_position requires a non-empty pattern as the first argument")
102✔
1232
    local hinge = a:rcell(rng)
102✔
1233
    for bcell in b:shuffled_cells(rng) do
103✔
1234
        local coordshift = bcell - hinge
101✔
1235
        if can_pack_at(a, b, coordshift) then
101✔
1236
            return coordshift
100✔
1237
        end
1238
    end
1239
    return nil
2✔
1240
end
1241

1242
--- Finds a center-weighted packing offset to place pattern a within pattern
1243
--- b as close as possible to the cell c. If no `c` is provided, then the centroid
1244
--- of pattern b is used.
1245
--
1246
-- @param a pattern to pack.
1247
-- @param b domain pattern.
1248
-- @param c (optional) cell to act as a center for packing.
1249
-- @return a coordinate shift if a valid position is found; nil otherwise.
1250
-- @usage
1251
-- local central_offset = pattern.find_central_packing_position(p, domain, c)
1252
function pattern.find_central_packing_position(a, b, c)
1✔
1253
    assert(getmetatable(a) == pattern, "pattern.find_central_packing_position requires a pattern as the first argument")
103✔
1254
    assert(getmetatable(b) == pattern, "pattern.find_central_packing_position requires a pattern as a second argument")
103✔
1255
    assert(a:size() > 0, "pattern.find_central_packing_position requires a non-empty pattern as the first argument")
103✔
1256
    if b:size() == 0 or a:size() > b:size() then return nil end
103✔
1257
    if c == nil then c = b:centroid() end
101✔
1258
    local hinge    = a:medoid()
101✔
1259
    local allcells = b:cell_list()
101✔
1260
    local function distance_to_c(k, j)
1261
        local adist = (k.x - c.x) * (k.x - c.x) + (k.y - c.y) * (k.y - c.y)
28,626✔
1262
        local bdist = (j.x - c.x) * (j.x - c.x) + (j.y - c.y) * (j.y - c.y)
28,626✔
1263
        return adist < bdist
28,626✔
1264
    end
1265
    table.sort(allcells, distance_to_c)
101✔
1266
    for i = 1, #allcells do
101✔
1267
        local coordshift = allcells[i] - hinge
101✔
1268
        if can_pack_at(a, b, coordshift) then
101✔
1269
            return coordshift
101✔
1270
        end
1271
    end
UNCOV
1272
    return nil
×
1273
end
1274

1275
--- Multipattern methods
1276
-- @section multipatterns
1277

1278
--- Returns a multipattern of the connected components within the pattern.
1279
-- Uses flood-fill to extract contiguous subpatterns.
1280
--
1281
-- @param ip pattern to analyze.
1282
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1283
-- @return a multipattern containing each connected component as a subpattern.
1284
-- @usage
1285
-- local components = p:connected_components(neighbourhood.moore())
1286
function pattern.connected_components(ip, nbh)
1✔
1287
    nbh = nbh or neighbourhood.moore()
19✔
1288
    assert(getmetatable(ip) == pattern, "pattern.connected_components requires a pattern as the first argument")
19✔
1289
    assert(getmetatable(nbh) == neighbourhood,
38✔
1290
        "pattern.connected_components requires a neighbourhood as the second argument")
19✔
1291
    local wp = pattern.clone(ip)
19✔
1292
    local mp = multipattern.new()
19✔
1293
    while pattern.size(wp) > 0 do
90✔
1294
        local seed_cell = wp:cells()()
71✔
1295
        local segment = pattern.floodfill(wp, seed_cell, nbh)
71✔
1296
        mp:insert(segment)
71✔
1297
        for x, y in segment:cell_coordinates() do
1,623✔
1298
            wp:remove(x, y)
1,552✔
1299
        end
1300
    end
1301
    return mp
19✔
1302
end
1303

1304
--- Returns a multipattern of the interior holes of the pattern.
1305
-- Interior holes are inactive regions completely surrounded by active cells.
1306
--
1307
-- @param ip pattern to analyze.
1308
-- @param nbh neighbourhood (default: neighbourhood.von_neumann()).
1309
-- @return a multipattern of interior hole subpatterns.
1310
-- @usage
1311
-- local holes = p:interior_holes(neighbourhood.von_neumann())
1312
function pattern.interior_holes(ip, nbh)
1✔
1313
    nbh = nbh or neighbourhood.von_neumann()
13✔
1314
    assert(getmetatable(ip) == pattern, "pattern.interior_holes requires a pattern as the first argument")
13✔
1315
    assert(ip:size() > 0, "pattern.interior_holes requires a non-empty pattern as the first argument")
13✔
1316
    assert(getmetatable(nbh) == neighbourhood, "pattern.interior_holes requires a neighbourhood as the second argument")
13✔
1317
    local primitives = require('forma.primitives')
13✔
1318
    local size = ip.max - ip.min + cell.new(1, 1)
13✔
1319
    local interior = primitives.square(size.x, size.y):translate(ip.min.x, ip.min.y) - ip
13✔
1320
    local connected_components = pattern.connected_components(interior, nbh)
13✔
1321
    local function fn(sp)
1322
        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✔
1323
            return true
14✔
1324
        end
1325
        return false
44✔
1326
    end
1327
    return connected_components:filter(fn)
13✔
1328
end
1329

1330
--- Partitions the pattern using binary space partitioning (BSP).
1331
-- Recursively subdivides contiguous rectangular areas until each partition's volume is below th_volume.
1332
--
1333
-- @param ip pattern to partition.
1334
-- @param th_volume threshold volume (number) for final partitions.
1335
-- @param alpha (optional) parameter for squareness of BSP.
1336
-- @return a multipattern of BSP subpatterns.
1337
-- @usage
1338
-- local partitions = p:bsp(50)
1339
function pattern.bsp(ip, th_volume, alpha)
1✔
1340
    assert(getmetatable(ip) == pattern, "pattern.bsp requires a pattern as an argument")
2✔
1341
    assert(th_volume, "pattern.bsp rules must specify a threshold volume for partitioning")
2✔
1342
    assert(th_volume > 0, "pattern.bsp rules must specify positive threshold volume for partitioning")
2✔
1343
    local available = ip:clone()
2✔
1344
    local mp = multipattern.new()
2✔
1345
    local bsp = require('forma.utils.bsp')
2✔
1346
    while pattern.size(available) > 0 do
4✔
1347
        local min_rect, max_rect = bsp.max_rectangle_coordinates(available, alpha)
2✔
1348
        if max_rect.x < min_rect.x then break end
2✔
1349
        bsp.split(min_rect, max_rect, th_volume, mp)
2✔
1350
        available = available - mp:union_all()
2✔
1351
    end
1352
    return mp
2✔
1353
end
1354

1355
--- Categorizes cells in the pattern based on neighbourhood configurations.
1356
-- Returns a multipattern with one subpattern per neighbourhood category.
1357
--
1358
-- @param ip pattern whose cells are to be categorized.
1359
-- @param nbh neighbourhood used for categorization.
1360
-- @return a multipattern with each category represented as a subpattern.
1361
-- @usage
1362
-- local categories = p:neighbourhood_categories(neighbourhood.moore())
1363
function pattern.neighbourhood_categories(ip, nbh)
1✔
1364
    assert(getmetatable(ip) == pattern, "pattern.neighbourhood_categories requires a pattern as a first argument")
2✔
1365
    assert(getmetatable(nbh) == neighbourhood,
4✔
1366
        "pattern.neighbourhood_categories requires a neighbourhood as a second argument")
2✔
1367
    local category_patterns = {}
2✔
1368
    for i = 1, nbh:get_ncategories(), 1 do
274✔
1369
        category_patterns[i] = pattern.new()
272✔
1370
    end
1371
    for icell in ip:cells() do
82✔
1372
        local cat = nbh:categorise(ip, icell)
80✔
1373
        category_patterns[cat]:insert(icell.x, icell.y)
80✔
1374
    end
1375
    return multipattern.new(category_patterns)
2✔
1376
end
1377

1378
--- Applies Perlin noise sampling to the pattern.
1379
-- Generates a multipattern by thresholding Perlin noise values at multiple levels.
1380
--
1381
-- @param ip pattern (domain) to sample from.
1382
-- @param freq frequency for Perlin noise (number).
1383
-- @param depth sampling depth (integer).
1384
-- @param thresholds table of threshold values (each between 0 and 1).
1385
-- @param rng (optional) random number generator (e.g., math.random).
1386
-- @return a multipattern with one component per threshold level.
1387
-- @usage
1388
-- local noise_samples = p:perlin(0.1, 4, {0.3, 0.5, 0.7})
1389
function pattern.perlin(ip, freq, depth, thresholds, rng)
1✔
1390
    if rng == nil then rng = math.random end
1✔
1391
    assert(getmetatable(ip) == pattern, "pattern.perlin requires a pattern as the first argument")
1✔
1392
    assert(type(freq) == "number", "pattern.perlin requires a numerical frequency value.")
1✔
1393
    assert(math.floor(depth) == depth, "pattern.perlin requires an integer sampling depth.")
1✔
1394
    assert(type(thresholds) == "table", "pattern.perlin requires a table of requested thresholds.")
1✔
1395
    for _, th in ipairs(thresholds) do
5✔
1396
        assert(th >= 0 and th <= 1, "pattern.perlin requires thresholds between 0 and 1.")
4✔
1397
    end
1398
    local samples = {}
1✔
1399
    for i = 1, #thresholds, 1 do
5✔
1400
        samples[i] = pattern.new()
4✔
1401
    end
1402
    local noise = require('forma.utils.noise')
1✔
1403
    local p_noise = noise.init(rng)
1✔
1404
    for ix, iy in ip:cell_coordinates() do
1,601✔
1405
        local nv = noise.perlin(p_noise, ix, iy, freq, depth)
1,600✔
1406
        for ith, th in ipairs(thresholds) do
8,000✔
1407
            if nv >= th then
6,400✔
1408
                samples[ith]:insert(ix, iy)
3,059✔
1409
            end
1410
        end
1411
    end
1412
    return multipattern.new(samples)
1✔
1413
end
1414

1415
--- Generates Voronoi tessellation segments for a domain based on seed points.
1416
--
1417
-- @param seeds pattern containing seed cells.
1418
-- @param domain pattern defining the tessellation domain.
1419
-- @param measure distance function (e.g., cell.euclidean).
1420
-- @return a multipattern of Voronoi segments.
1421
-- @usage
1422
-- local segments = pattern.voronoi(seeds, domain, cell.euclidean)
1423
function pattern.voronoi(seeds, domain, measure)
1✔
1424
    assert(getmetatable(seeds) == pattern, "pattern.voronoi requires a pattern as the first argument")
7✔
1425
    assert(getmetatable(domain) == pattern, "pattern.voronoi requires a pattern as a second argument")
7✔
1426
    assert(pattern.size(seeds) > 0, "pattern.voronoi requires at least one target cell/seed")
7✔
1427
    local seedcells = {}
7✔
1428
    local segments  = {}
7✔
1429
    for iseed in seeds:cells() do
77✔
1430
        assert(domain:has_cell(iseed.x, iseed.y), "forma.voronoi: cell outside of domain")
70✔
1431
        table.insert(seedcells, iseed)
70✔
1432
        table.insert(segments, pattern.new())
70✔
1433
    end
1434
    for dp in domain:cells() do
707✔
1435
        local min_cell = 1
700✔
1436
        local min_dist = measure(dp, seedcells[1])
700✔
1437
        for j = 2, #seedcells, 1 do
7,000✔
1438
            local distance = measure(dp, seedcells[j])
6,300✔
1439
            if distance < min_dist then
6,300✔
1440
                min_cell = j
1,027✔
1441
                min_dist = distance
1,027✔
1442
            end
1443
        end
1444
        segments[min_cell]:insert(dp.x, dp.y)
700✔
1445
    end
1446
    return multipattern.new(segments)
7✔
1447
end
1448

1449
--- Performs centroidal Voronoi tessellation (Lloyd's algorithm) on a set of seeds.
1450
-- Iteratively relaxes seed positions until convergence or a maximum number of iterations.
1451
--
1452
-- @param seeds initial seed pattern.
1453
-- @param domain tessellation domain pattern.
1454
-- @param measure distance function (e.g., cell.euclidean).
1455
-- @param max_ite (optional) maximum iterations (default: 30).
1456
-- @return a multipattern of Voronoi segments, a pattern of relaxed seed positions, and a boolean convergence flag.
1457
-- @usage
1458
-- local segments, relaxed_seeds, converged = pattern.voronoi_relax(seeds, domain, cell.euclidean)
1459
function pattern.voronoi_relax(seeds, domain, measure, max_ite)
1✔
1460
    if max_ite == nil then max_ite = 30 end
1✔
1461
    assert(getmetatable(seeds) == pattern, "pattern.voronoi_relax requires a pattern as the first argument")
1✔
1462
    assert(getmetatable(domain) == pattern, "pattern.voronoi_relax requires a pattern as a second argument")
1✔
1463
    assert(type(measure) == 'function', "pattern.voronoi_relax requires a distance measure as an argument")
1✔
1464
    assert(seeds:size() <= domain:size(), "pattern.voronoi_relax: too many seeds for domain")
1✔
1465
    local current_seeds = seeds:clone()
1✔
1466
    for ite = 1, max_ite, 1 do
4✔
1467
        local tesselation = pattern.voronoi(current_seeds, domain, measure)
4✔
1468
        local next_seeds  = pattern.new()
4✔
1469
        for iseg = 1, tesselation:n_components(), 1 do
44✔
1470
            if tesselation[iseg]:size() > 0 then
40✔
1471
                local cent = tesselation[iseg]:centroid()
40✔
1472
                if domain:has_cell(cent.x, cent.y) then
40✔
1473
                    if not next_seeds:has_cell(cent.x, cent.y) then
40✔
1474
                        next_seeds:insert(cent.x, cent.y)
40✔
1475
                    end
1476
                else
UNCOV
1477
                    local med = tesselation[iseg]:medoid()
×
UNCOV
1478
                    if not next_seeds:has_cell(med.x, med.y) then
×
UNCOV
1479
                        next_seeds:insert(med.x, med.y)
×
1480
                    end
1481
                end
1482
            end
1483
        end
1484
        if current_seeds == next_seeds then
4✔
1485
            return tesselation, current_seeds, true
1✔
1486
        elseif ite == max_ite then
3✔
1487
            return tesselation, current_seeds, false
×
1488
        end
1489
        current_seeds = next_seeds
3✔
1490
    end
UNCOV
1491
    assert(false, "This should not be reachable")
×
1492
end
1493

1494
--- Test components
1495
-- @section test
1496

1497
--- Returns the maximum allowed coordinate for spatial hashing.
1498
--
1499
-- @return maximum coordinate value (number).
1500
-- @usage
1501
-- local max_coord = pattern.get_max_coordinate()
1502
function pattern.get_max_coordinate()
1✔
1503
    return MAX_COORDINATE
1✔
1504
end
1505

1506
--- Tests the conversion between (x, y) coordinates and the spatial hash key.
1507
--
1508
-- @param x test x-coordinate (number).
1509
-- @param y test y-coordinate (number).
1510
-- @return boolean true if the conversion is correct, false otherwise.
1511
-- @usage
1512
-- local valid = pattern.test_coordinate_map(10, 20)
1513
function pattern.test_coordinate_map(x, y)
1✔
1514
    assert(type(x) == 'number' and type(y) == 'number',
20✔
1515
        "pattern.test_coordinate_map requires two numbers as arguments")
10✔
1516
    local key = coordinates_to_key(x, y)
10✔
1517
    local tx, ty = key_to_coordinates(key)
10✔
1518
    return (x == tx) and (y == ty)
10✔
1519
end
1520

1521
--- Prints the pattern within an optional domain, line-by-line.
1522
-- @param ip      pattern to render.
1523
-- @param char    (optional) single-character to draw “on” cells.
1524
-- @param domain  (optional) pattern defining the bounding box.
1525
-- @param printer (optional) function(line:string); defaults to io.write(line.."\n").
1526
function pattern.print(ip, char, domain, printer)
1✔
1527
    assert(getmetatable(ip) == pattern, "pattern.print requires a pattern as the first argument")
1✔
1528
    local onchar  = char or ip.onchar
1✔
1529
    local offchar = ip.offchar
1✔
1530
    domain        = domain or ip
1✔
1531
    assert(getmetatable(domain) == pattern, "pattern.print requires a pattern as the domain argument")
1✔
1532

UNCOV
1533
    local print_line = printer
×
1534
        or function(line) io.write(line .. "\n") end
1✔
1535

1536
    for y = domain.min.y, domain.max.y, 1 do
2✔
1537
        local chars = {}
1✔
1538
        for x = domain.min.x, domain.max.x, 1 do
2✔
1539
            chars[#chars + 1] = ip:has_cell(x, y) and onchar or offchar
1✔
1540
        end
1541
        print_line(table.concat(chars))
1✔
1542
    end
1543
end
1544

1545
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