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

nhartland / forma / 22256968205

21 Feb 2026 12:39PM UTC coverage: 97.368% (-1.0%) from 98.384%
22256968205

Pull #29

github

web-flow
Merge b30cd7abd into 319687f37
Pull Request #29: Dev PR

471 of 487 new or added lines in 15 files covered. (96.71%)

26 existing lines in 4 files now uncovered.

2109 of 2166 relevant lines covered (97.37%)

7563.81 hits per line

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

96.58
/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,550,978✔
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)
108,997✔
75
    local xp = (key - yp) / COORDINATE_SPAN
108,997✔
76
    return xp - MAX_COORDINATE, yp - MAX_COORDINATE
108,997✔
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 = {}
6,424✔
91

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

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

99
    np.cellkey = {} -- Table consisting of a list of coordinate keys.
6,424✔
100
    np.cellmap = {} -- Spatial hash of coordinate key to its index in cellkey.
6,424✔
101
    np.bbox_dirty = false -- Whether the bounding box needs recomputation.
6,424✔
102

103
    np = setmetatable(np, pattern)
6,424✔
104

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

130
    return np
6,424✔
131
end
132

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

141
    for i = 1, #ip.cellkey, 1 do
23,099✔
142
        local key = ip.cellkey[i]
21,807✔
143
        newpat.cellkey[i] = key
21,807✔
144
        newpat.cellmap[key] = i
21,807✔
145
    end
146

147
    newpat.min = ip.min:clone()
1,292✔
148
    newpat.max = ip.max:clone()
1,292✔
149

150
    newpat.offchar = ip.offchar
1,292✔
151
    newpat.onchar  = ip.onchar
1,292✔
152
    newpat.bbox_dirty = ip.bbox_dirty
1,292✔
153

154
    return newpat
1,292✔
155
end
156

157
--- Inserts a new cell into the pattern.
158
-- Returns the modified pattern to allow for method chaining.
159
--
160
-- @param ip pattern to modify.
161
-- @param x x-coordinate (integer) of the new cell.
162
-- @param y y-coordinate (integer) of the new cell.
163
-- @return the updated pattern (for cascading calls).
164
function pattern.insert(ip, x, y)
1✔
165
    assert(floor(x) == x, 'pattern.insert requires an integer for the x coordinate')
163,025✔
166
    assert(floor(y) == y, 'pattern.insert requires an integer for the y coordinate')
163,025✔
167

168
    local key = coordinates_to_key(x, y)
163,025✔
169
    assert(ip.cellmap[key] == nil, "pattern.insert cannot duplicate cells")
163,025✔
170
    local new_index = #ip.cellkey + 1
163,025✔
171
    ip.cellkey[new_index] = key
163,025✔
172
    ip.cellmap[key] = new_index
163,025✔
173

174
    -- Reset pattern extent.
175
    ip.max.x = max(ip.max.x, x)
163,025✔
176
    ip.max.y = max(ip.max.y, y)
163,025✔
177
    ip.min.x = min(ip.min.x, x)
163,025✔
178
    ip.min.y = min(ip.min.y, y)
163,025✔
179

180
    return ip
163,025✔
181
end
182

183
--- Removes a cell from the pattern.
184
-- Returns the modified pattern to allow for method chaining.
185
-- The bounding box is lazily recomputed when next accessed if the removed
186
-- cell was on the boundary.
187
--
188
-- @param ip pattern to modify.
189
-- @param x x-coordinate (integer) of the cell to remove.
190
-- @param y y-coordinate (integer) of the cell to remove.
191
-- @return the updated pattern (for cascading calls).
192
function pattern.remove(ip, x, y)
1✔
193
    local key_to_remove = coordinates_to_key(x, y)
1,855✔
194
    local index_to_remove = ip.cellmap[key_to_remove]
1,855✔
195

196
    if index_to_remove then
1,855✔
197
        local last_index = #ip.cellkey
1,854✔
198
        local last_key = ip.cellkey[last_index]
1,854✔
199

200
        -- Swap
201
        ip.cellkey[index_to_remove] = last_key
1,854✔
202
        ip.cellmap[last_key] = index_to_remove
1,854✔
203

204
        -- Pop
205
        ip.cellkey[last_index] = nil
1,854✔
206
        ip.cellmap[key_to_remove] = nil
1,854✔
207

208
        -- Mark bounding box dirty if removed cell was on the boundary
209
        if not ip.bbox_dirty then
1,854✔
210
            if x == ip.min.x or x == ip.max.x or y == ip.min.y or y == ip.max.y then
83✔
211
                ip.bbox_dirty = true
25✔
212
            end
213
        end
214
    end
215

216
    return ip
1,855✔
217
end
218

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

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

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

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

259
--- Recalculates the bounding box of the pattern.
260
-- This is a slow O(N) operation. It is called automatically (lazily) when
261
-- the bounding box is accessed after a `remove` operation on a boundary cell.
262
-- Manual calls are only needed after directly modifying pattern internals.
263
-- @param ip pattern to process.
264
function pattern.recalculate_bounding_box(ip)
1✔
265
    ip.max.x, ip.max.y = -math.huge, -math.huge
2✔
266
    ip.min.x, ip.min.y = math.huge, math.huge
2✔
267
    for x, y in ip:cell_coordinates() do
8✔
268
        ip.max.x = max(ip.max.x, x)
6✔
269
        ip.max.y = max(ip.max.y, y)
6✔
270
        ip.min.x = min(ip.min.x, x)
6✔
271
        ip.min.y = min(ip.min.y, y)
6✔
272
    end
273
    ip.bbox_dirty = false
2✔
274
end
275

276
--- Ensures the bounding box is up-to-date, recomputing if necessary.
277
-- @param ip pattern to check.
278
local function ensure_bbox(ip)
279
    if ip.bbox_dirty then
30✔
NEW
280
        pattern.recalculate_bounding_box(ip)
×
281
    end
282
end
283

284
--- Comparator function to sort patterns by their size (descending).
285
--
286
-- @param pa first pattern.
287
-- @param pb second pattern.
288
-- @return boolean true if pa's size is greater than pb's.
289
function pattern.size_sort(pa, pb)
1✔
290
    return pa:size() > pb:size()
360✔
291
end
292

293
--- Comparator function to sort patterns by their size (ascending).
294
--
295
-- @param pa first pattern.
296
-- @param pb second pattern.
297
-- @return boolean true if pa's size is less than pb's.
298
function pattern.inverse_size_sort(pa, pb)
1✔
NEW
299
    return pa:size() < pb:size()
×
300
end
301

302
--- Computes how densely the bounding box is filled.
303
-- Returns zero for an empty pattern.
304
--
305
-- @param ip input pattern
306
-- @return float the fraction of the bounding box that is occupied
307
function pattern.bounding_box_density(ip)
1✔
308
    assert(getmetatable(ip) == pattern,
6✔
309
        "bounding_box_density: first argument must be a forma.pattern")
3✔
310
    if ip:size() == 0 then return 0 end
3✔
311
    ensure_bbox(ip)
3✔
312
    local bb_area = (ip.max.x - ip.min.x + 1) * (ip.max.y - ip.min.y + 1)
3✔
313
    return ip:size() / bb_area
3✔
314
end
315

316
--- Computes the asymmetry of the pattern's bounding box.
317
-- Returns zero in the case of an empty pattern.
318
--
319
-- @param ip input pattern
320
-- @return float the ratio of the bounding box's longest to shortest edge
321
function pattern.bounding_box_asymmetry(ip)
1✔
322
    assert(getmetatable(ip) == pattern,
4✔
323
        "bounding_box_asymmetry: first argument must be a forma.pattern")
2✔
324
    if ip:size() == 0 then return 0 end
2✔
325
    ensure_bbox(ip)
2✔
NEW
326
    return (
×
327
        (max((ip.max.x - ip.min.x), (ip.max.y - ip.min.y)) + 1)
2✔
328
        / (min((ip.max.x - ip.min.x), (ip.max.y - ip.min.y)) + 1)
2✔
329
    )
2✔
330
end
331

332
--- Counts active neighbors around a specified cell within the pattern.
333
-- Can be invoked with either a cell object or with x and y coordinates.
334
--
335
-- @param p a pattern.
336
-- @param nbh a neighbourhood (e.g., neighbourhood.moore()).
337
-- @param arg1 either a cell (with x and y fields) or the x-coordinate (integer).
338
-- @param arg2 (optional) the y-coordinate (integer) if arg1 is not a cell.
339
-- @return integer count of active neighbouring cells.
340
function pattern.count_neighbors(p, nbh, arg1, arg2)
1✔
UNCOV
341
    assert(getmetatable(p) == pattern,
×
UNCOV
342
        "count_neighbors: first argument must be a forma.pattern")
×
UNCOV
343
    assert(getmetatable(nbh),
×
UNCOV
344
        "count_neighbors: second argument must be a neighbourhood")
×
345

346
    local x, y
UNCOV
347
    if type(arg1) == 'table' and arg1.x and arg1.y then
×
UNCOV
348
        x, y = arg1.x, arg1.y
×
349
    else
UNCOV
350
        x, y = arg1, arg2
×
351
    end
352

UNCOV
353
    local count = 0
×
UNCOV
354
    for i = 1, #nbh, 1 do
×
UNCOV
355
        local offset = nbh[i]
×
UNCOV
356
        local nx, ny = x + offset.x, y + offset.y
×
UNCOV
357
        if p:has_cell(nx, ny) then
×
UNCOV
358
            count = count + 1
×
359
        end
360
    end
UNCOV
361
    return count
×
362
end
363

364
--- Returns a list (table) of active cells in the pattern.
365
--
366
-- @param ip pattern to list cells from.
367
-- @return table of cell objects.
368
function pattern.cell_list(ip)
1✔
369
    assert(getmetatable(ip) == pattern, "pattern.cell_list requires a pattern as the first argument")
220✔
370
    local newlist = {}
220✔
371
    for icell in ip:cells() do
5,501✔
372
        newlist[#newlist + 1] = icell
5,281✔
373
    end
374
    return newlist
220✔
375
end
376

377
--- Computes the edit distance between two patterns (the total number of differing cells).
378
--
379
-- @param a first pattern.
380
-- @param b second pattern.
381
-- @return integer representing the edit distance.
382
function pattern.edit_distance(a, b)
1✔
383
    assert(getmetatable(a) == pattern, "pattern.edit_distance requires a pattern as the first argument")
5✔
384
    assert(getmetatable(b) == pattern, "pattern.edit_distance requires a pattern as the second argument")
5✔
385
    local common = pattern.intersect(a, b)
5✔
386
    local edit_distance = (a - common):size() + (b - common):size()
5✔
387
    return edit_distance
5✔
388
end
389

390
--- Set operations.
391
-- @section set_ops
392

393
--- Returns the union of a set of patterns.
394
--
395
-- @param ... a table of patterns or a list of pattern arguments.
396
-- @return a new pattern that is the union of the provided patterns.
397
function pattern.union(...)
1✔
398
    local patterns = { ... }
35✔
399
    if #patterns == 1 and type(patterns[1]) == 'table' then
35✔
400
        patterns = patterns[1]
10✔
401
    end
402

403
    if #patterns == 0 then return pattern.new() end
35✔
404
    if #patterns == 1 then return patterns[1]:clone() end
35✔
405

406
    local all_keys = {}
35✔
407
    for _, p in ipairs(patterns) do
5,165✔
408
        assert(getmetatable(p) == pattern, "pattern.union requires a pattern as an argument")
5,130✔
409
        for i = 1, #p.cellkey do
46,190✔
410
            all_keys[p.cellkey[i]] = true
41,060✔
411
        end
412
    end
413

414
    local total = pattern.new()
35✔
415
    for key in pairs(all_keys) do
41,076✔
416
        local x, y = key_to_coordinates(key)
41,041✔
417
        total:insert(x, y)
41,041✔
418
    end
419
    return total
35✔
420
end
421

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

450
--- Returns the symmetric difference (XOR) of two patterns.
451
-- Cells are included if they exist in either pattern but not in both.
452
--
453
-- @param a first pattern.
454
-- @param b second pattern.
455
-- @return a new pattern representing the symmetric difference.
456
function pattern.xor(a, b)
1✔
457
    assert(getmetatable(a) == pattern, "pattern.xor requires a pattern as the first argument")
3✔
458
    assert(getmetatable(b) == pattern, "pattern.xor requires a pattern as the second argument")
3✔
459
    return (a + b) - (a * b)
3✔
460
end
461

462
--- Iterators
463
-- @section iterators
464

465
--- Iterator over active cells in the pattern.
466
--
467
-- @param ip pattern to iterate over.
468
-- @return iterator that returns each active cell as a cell object.
469
-- @usage
470
-- for cell in p:cells() do
471
--     print(cell.x, cell.y)
472
-- end
473
function pattern.cells(ip)
1✔
474
    assert(getmetatable(ip) == pattern, "pattern.cells requires a pattern as the first argument")
1,335✔
475
    local icell, ncells = 0, ip:size()
1,335✔
476
    return function()
477
        icell = icell + 1
14,557✔
478
        if icell <= ncells then
14,557✔
479
            local ikey = ip.cellkey[icell]
13,293✔
480
            local x, y = key_to_coordinates(ikey)
13,293✔
481
            return cell.new(x, y)
13,293✔
482
        end
483
    end
484
end
485

486
--- Iterator over active cell coordinates (x, y) in the pattern.
487
--
488
-- @param ip pattern to iterate over.
489
-- @return iterator that returns the x and y coordinates of each active cell.
490
-- @usage
491
-- for x, y in p:cell_coordinates() do
492
--     print(x, y)
493
-- end
494
function pattern.cell_coordinates(ip)
1✔
495
    assert(getmetatable(ip) == pattern, "pattern.cell_coordinates requires a pattern as the first argument")
2,824✔
496
    local icell, ncells = 0, ip:size()
2,824✔
497
    return function()
498
        icell = icell + 1
55,716✔
499
        if icell <= ncells then
55,716✔
500
            local ikey = ip.cellkey[icell]
52,892✔
501
            return key_to_coordinates(ikey)
52,892✔
502
        end
503
    end
504
end
505

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

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

557
--- Metamethods
558
-- @section metamethods
559

560
--- Renders the pattern as a string.
561
-- Active cells are shown with pattern.onchar and inactive cells with pattern.offchar.
562
--
563
-- @param ip pattern to render.
564
-- @return string representation of the pattern.
565
-- @usage
566
-- print(p)
567
function pattern.__tostring(ip)
1✔
568
    ensure_bbox(ip)
1✔
569
    local str = '- pattern origin: ' .. tostring(ip.min) .. '\n'
1✔
570
    for y = ip.min.y, ip.max.y, 1 do
6✔
571
        for x = ip.min.x, ip.max.x, 1 do
30✔
572
            local char = ip:has_cell(x, y) and ip.onchar or ip.offchar
25✔
573
            str = str .. char
25✔
574
        end
575
        str = str .. '\n'
5✔
576
    end
577
    return str
1✔
578
end
579

580
--- Adds two patterns using the '+' operator (i.e. returns their union).
581
--
582
-- @param a first pattern.
583
-- @param b second pattern.
584
-- @return a new pattern representing the union of a and b.
585
-- @usage
586
-- local combined = p1 + p2
587
function pattern.__add(a, b)
1✔
588
    assert(getmetatable(a) == pattern, "pattern addition requires a pattern as the first argument")
23✔
589
    assert(getmetatable(b) == pattern, "pattern addition requires a pattern as the second argument")
23✔
590
    return pattern.union(a, b)
23✔
591
end
592

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

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

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

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

662
--- Cell accessors
663
-- @section cell_accessors
664

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

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

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

735
--- Transformations
736
-- @section transformations
737

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

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

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

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

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

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

843
--- Random subpatterns
844
-- @section subpatterns
845

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

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

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

948
--- Deterministic subpatterns
949
-- @section det_subpatterns
950

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

958
    while head < tail do
18,995✔
959
        local cx, cy = q[head], q[head + 1]
18,714✔
960
        head = head + 2
18,714✔
961

962
        for i = 1, #nbh do
162,426✔
963
            local nx = nbh[i].x + cx
143,712✔
964
            local ny = nbh[i].y + cy
143,712✔
965
            if domain:has_cell(nx, ny) and not retpat:has_cell(nx, ny) then
143,712✔
966
                retpat:insert(nx, ny)
18,433✔
967
                q[tail + 1] = nx
18,433✔
968
                q[tail + 2] = ny
18,433✔
969
                tail = tail + 2
18,433✔
970
            end
971
        end
972
    end
973
end
974

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

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

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

1040
--- Returns a thinned (skeletonized) version of the pattern.
1041
-- This uses the Zhang-Suen algorithm thinning algorithm assumes that the
1042
-- neighbourhood is 8-connected (moore), but it is very efficient.
1043
-- @param ip pattern to thin.
1044
-- @return a new, thinned pattern.
1045
-- @usage
1046
-- local thin_p = p:thin()
1047
function pattern.thin(ip)
1✔
1048
    assert(getmetatable(ip) == pattern, "pattern.thin requires a pattern as the first argument")
2✔
1049
    local p = pattern.clone(ip)
2✔
1050
    -- Utility callback to remove cells from the pattern
1051
    local function process_changes(to_remove)
1052
        for _, c in ipairs(to_remove) do
10✔
1053
            p:remove(c[1], c[2])
8✔
1054
        end
1055
    end
1056
    -- Main Zhang-Suen thinning loop
1057
    local zs = require('forma.utils.zhang_suen')
2✔
1058
    local changed = true
2✔
1059
    while changed do
6✔
1060
        changed = false
4✔
1061
        -- Perform pass A
1062
        changed = changed or zs.pass(p, zs.passA_conditions, process_changes)
4✔
1063
        -- Perform pass B
1064
        changed = changed or zs.pass(p, zs.passB_conditions, process_changes)
4✔
1065
    end
1066

1067
    -- Recalculate bounding box as cells may have been removed
1068
    p:recalculate_bounding_box()
2✔
1069

1070
    return p
2✔
1071
end
1072

1073
--- Morphological operations
1074
-- @section morphological
1075

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

1107
--- Returns the dilation of the pattern.
1108
-- Each active cell contributes its neighbours (as defined by nbh) to the result.
1109
--
1110
-- @param ip pattern to dilate.
1111
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1112
-- @return a new, dilated pattern.
1113
-- @usage
1114
-- local dilated = p:dilate(neighbourhood.moore())
1115
function pattern.dilate(ip, nbh)
1✔
1116
    nbh = nbh or neighbourhood.moore()
8✔
1117
    assert(getmetatable(ip) == pattern, "pattern.dilate requires a pattern as the first argument")
8✔
1118
    assert(getmetatable(nbh) == neighbourhood, "pattern.dilate requires a neighbourhood as the second argument")
8✔
1119

1120
    local dilated_keys = {}
8✔
1121
    -- Add original cells and their neighbours to a single set to handle overlaps.
1122
    for x, y in ip:cell_coordinates() do
31✔
1123
        dilated_keys[coordinates_to_key(x, y)] = true
23✔
1124
        for j = 1, #nbh, 1 do
199✔
1125
            local offset = nbh[j]
176✔
1126
            local nx = x + offset.x
176✔
1127
            local ny = y + offset.y
176✔
1128
            dilated_keys[coordinates_to_key(nx, ny)] = true
176✔
1129
        end
1130
    end
1131

1132
    local np = pattern.new()
8✔
1133
    for key in pairs(dilated_keys) do
104✔
1134
        local x, y = key_to_coordinates(key)
96✔
1135
        np:insert(x, y)
96✔
1136
    end
1137
    return np
8✔
1138
end
1139

1140
--- Returns the morphological gradient of the pattern.
1141
-- Computes the difference between the dilation and erosion.
1142
--
1143
-- @param ip pattern to process.
1144
-- @param nbh neighbourhood for dilation/erosion (default: neighbourhood.moore()).
1145
-- @return a new pattern representing the gradient.
1146
-- @usage
1147
-- local grad = p:gradient(neighbourhood.moore())
1148
function pattern.gradient(ip, nbh)
1✔
1149
    nbh = nbh or neighbourhood.moore()
2✔
1150
    assert(getmetatable(ip) == pattern, "pattern.gradient requires a pattern as the first argument")
2✔
1151
    assert(getmetatable(nbh) == neighbourhood, "pattern.gradient requires a neighbourhood as the second argument")
2✔
1152
    return pattern.dilate(ip, nbh) - pattern.erode(ip, nbh)
2✔
1153
end
1154

1155
--- Returns the morphological opening of the pattern.
1156
-- Performs erosion followed by dilation to remove small artifacts.
1157
--
1158
-- @param ip pattern to process.
1159
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1160
-- @return a new, opened pattern.
1161
-- @usage
1162
-- local opened = p:opening(neighbourhood.moore())
1163
function pattern.opening(ip, nbh)
1✔
1164
    nbh = nbh or neighbourhood.moore()
1✔
1165
    assert(getmetatable(ip) == pattern, "pattern.opening requires a pattern as the first argument")
1✔
1166
    assert(getmetatable(nbh) == neighbourhood, "pattern.opening requires a neighbourhood as the second argument")
1✔
1167
    local eroded = pattern.erode(ip, nbh)
1✔
1168
    return pattern.dilate(eroded, nbh)
1✔
1169
end
1170

1171
--- Returns the morphological closing of the pattern.
1172
-- Performs dilation followed by erosion to fill small holes.
1173
--
1174
-- @param ip pattern to process.
1175
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1176
-- @return a new, closed pattern.
1177
-- @usage
1178
-- local closed = p:closing(neighbourhood.moore())
1179
function pattern.closing(ip, nbh)
1✔
1180
    nbh = nbh or neighbourhood.moore()
1✔
1181
    assert(getmetatable(ip) == pattern, "pattern.closing requires a pattern as the first argument")
1✔
1182
    assert(getmetatable(nbh) == neighbourhood, "pattern.closing requires a neighbourhood as the second argument")
1✔
1183
    local dilated = pattern.dilate(ip, nbh)
1✔
1184
    return pattern.erode(dilated, nbh)
1✔
1185
end
1186

1187
--- Returns a pattern of cells that form the interior hull.
1188
-- These are cells that neighbor inactive cells while still belonging to the pattern.
1189
--
1190
-- @param ip pattern to process.
1191
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1192
-- @return a new pattern representing the interior hull.
1193
-- @usage
1194
-- local interior = p:interior_hull(neighbourhood.moore())
1195
function pattern.interior_hull(ip, nbh)
1✔
1196
    nbh = nbh or neighbourhood.moore()
3✔
1197
    assert(getmetatable(ip) == pattern, "pattern.interior_hull requires a pattern as the first argument")
3✔
1198
    assert(getmetatable(nbh) == neighbourhood, "pattern.interior_hull requires a neighbourhood as an argument")
3✔
1199
    return (ip - pattern.erode(ip, nbh))
3✔
1200
end
1201

1202
--- Returns a pattern of cells that form the exterior hull.
1203
-- This consists of inactive neighbours of the pattern, useful for enlarging or
1204
-- determining non-overlapping borders.
1205
--
1206
-- @param ip pattern to process.
1207
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1208
-- @return a new pattern representing the exterior hull.
1209
-- @usage
1210
-- local exterior = p:exterior_hull(neighbourhood.moore())
1211
function pattern.exterior_hull(ip, nbh)
1✔
1212
    nbh = nbh or neighbourhood.moore()
3✔
1213
    assert(getmetatable(ip) == pattern, "pattern.exterior_hull requires a pattern as the first argument")
3✔
1214
    assert(getmetatable(nbh) == neighbourhood, "pattern.exterior_hull requires a neighbourhood as an argument")
3✔
1215
    return (pattern.dilate(ip, nbh) - ip)
3✔
1216
end
1217

1218
--- Packing methods
1219
-- @section packing
1220

1221
-- Check if pattern a fits entirely within domain b when shifted by coordshift
1222
local function can_pack_at(a, b, coordshift)
1223
    for acell in a:cells() do
403✔
1224
        local shifted = acell + coordshift
202✔
1225
        if not b:has_cell(shifted.x, shifted.y) then
202✔
1226
            return false
1✔
1227
        end
1228
    end
1229
    return true
201✔
1230
end
1231

1232
--- Finds a packing offset where pattern a fits entirely within domain b.
1233
-- Returns a coordinate shift that, when applied to a, makes it tile inside b.
1234
--
1235
-- @param a pattern to pack.
1236
-- @param b domain pattern in which to pack a.
1237
-- @param rng (optional) random number generator (e.g., math.random).
1238
-- @return a cell (as a coordinate shift) if a valid position is found; nil otherwise.
1239
-- @usage
1240
-- local offset = pattern.find_packing_position(p, domain)
1241
function pattern.find_packing_position(a, b, rng)
1✔
1242
    assert(getmetatable(a) == pattern, "pattern.find_packing_position requires a pattern as the first argument")
102✔
1243
    assert(getmetatable(b) == pattern, "pattern.find_packing_position requires a pattern as a second argument")
102✔
1244
    assert(a:size() > 0, "pattern.find_packing_position requires a non-empty pattern as the first argument")
102✔
1245
    local hinge = a:rcell(rng)
102✔
1246
    for bcell in b:shuffled_cells(rng) do
103✔
1247
        local coordshift = bcell - hinge
101✔
1248
        if can_pack_at(a, b, coordshift) then
101✔
1249
            return coordshift
100✔
1250
        end
1251
    end
1252
    return nil
2✔
1253
end
1254

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

1288
--- Multipattern methods
1289
-- @section multipatterns
1290

1291
--- Returns a multipattern of the connected components within the pattern.
1292
-- Uses flood-fill to extract contiguous subpatterns.
1293
--
1294
-- @param ip pattern to analyze.
1295
-- @param nbh neighbourhood (default: neighbourhood.moore()).
1296
-- @return a multipattern containing each connected component as a subpattern.
1297
-- @usage
1298
-- local components = p:connected_components(neighbourhood.moore())
1299
function pattern.connected_components(ip, nbh)
1✔
1300
    nbh = nbh or neighbourhood.moore()
18✔
1301
    assert(getmetatable(ip) == pattern, "pattern.connected_components requires a pattern as the first argument")
18✔
1302
    assert(getmetatable(nbh) == neighbourhood,
36✔
1303
        "pattern.connected_components requires a neighbourhood as the second argument")
18✔
1304
    local wp = pattern.clone(ip)
18✔
1305
    local mp = multipattern.new()
18✔
1306
    while pattern.size(wp) > 0 do
88✔
1307
        local seed_cell = wp:cells()()
70✔
1308
        local segment = pattern.floodfill(wp, seed_cell, nbh)
70✔
1309
        mp:insert(segment)
70✔
1310
        for x, y in segment:cell_coordinates() do
1,611✔
1311
            wp:remove(x, y)
1,541✔
1312
        end
1313
    end
1314
    return mp
18✔
1315
end
1316

1317
--- Returns a multipattern of the interior holes of the pattern.
1318
-- Interior holes are inactive regions completely surrounded by active cells.
1319
--
1320
-- @param ip pattern to analyze.
1321
-- @param nbh neighbourhood (default: neighbourhood.von_neumann()).
1322
-- @return a multipattern of interior hole subpatterns.
1323
-- @usage
1324
-- local holes = p:interior_holes(neighbourhood.von_neumann())
1325
function pattern.interior_holes(ip, nbh)
1✔
1326
    nbh = nbh or neighbourhood.von_neumann()
13✔
1327
    assert(getmetatable(ip) == pattern, "pattern.interior_holes requires a pattern as the first argument")
13✔
1328
    assert(ip:size() > 0, "pattern.interior_holes requires a non-empty pattern as the first argument")
13✔
1329
    assert(getmetatable(nbh) == neighbourhood, "pattern.interior_holes requires a neighbourhood as the second argument")
13✔
1330
    ensure_bbox(ip)
13✔
1331
    local primitives = require('forma.primitives')
13✔
1332
    local size = ip.max - ip.min + cell.new(1, 1)
13✔
1333
    local interior = primitives.square(size.x, size.y):translate(ip.min.x, ip.min.y) - ip
13✔
1334
    local connected_components = pattern.connected_components(interior, nbh)
13✔
1335
    local function fn(sp)
1336
        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✔
1337
            return true
14✔
1338
        end
1339
        return false
44✔
1340
    end
1341
    return connected_components:filter(fn)
13✔
1342
end
1343

1344
--- Partitions the pattern using binary space partitioning (BSP).
1345
-- Recursively subdivides contiguous rectangular areas until each partition's volume is below th_volume.
1346
--
1347
-- @param ip pattern to partition.
1348
-- @param th_volume threshold volume (number) for final partitions.
1349
-- @param alpha (optional) parameter for squareness of BSP.
1350
-- @return a multipattern of BSP subpatterns.
1351
-- @usage
1352
-- local partitions = p:bsp(50)
1353
function pattern.bsp(ip, th_volume, alpha)
1✔
1354
    assert(getmetatable(ip) == pattern, "pattern.bsp requires a pattern as an argument")
2✔
1355
    assert(th_volume, "pattern.bsp rules must specify a threshold volume for partitioning")
2✔
1356
    assert(th_volume > 0, "pattern.bsp rules must specify positive threshold volume for partitioning")
2✔
1357
    local available = ip:clone()
2✔
1358
    local mp = multipattern.new()
2✔
1359
    local bsp = require('forma.utils.bsp')
2✔
1360
    while pattern.size(available) > 0 do
4✔
1361
        local min_rect, max_rect = bsp.max_rectangle_coordinates(available, alpha)
2✔
1362
        if max_rect.x < min_rect.x then break end
2✔
1363
        bsp.split(min_rect, max_rect, th_volume, mp)
2✔
1364
        available = available - mp:union_all()
2✔
1365
    end
1366
    return mp
2✔
1367
end
1368

1369
--- Categorizes cells in the pattern based on neighbourhood configurations.
1370
-- Returns a multipattern with one subpattern per neighbourhood category.
1371
--
1372
-- @param ip pattern whose cells are to be categorized.
1373
-- @param nbh neighbourhood used for categorization.
1374
-- @return a multipattern with each category represented as a subpattern.
1375
-- @usage
1376
-- local categories = p:neighbourhood_categories(neighbourhood.moore())
1377
function pattern.neighbourhood_categories(ip, nbh)
1✔
1378
    assert(getmetatable(ip) == pattern, "pattern.neighbourhood_categories requires a pattern as a first argument")
2✔
1379
    assert(getmetatable(nbh) == neighbourhood,
4✔
1380
        "pattern.neighbourhood_categories requires a neighbourhood as a second argument")
2✔
1381
    local category_patterns = {}
2✔
1382
    for i = 1, nbh:get_ncategories(), 1 do
274✔
1383
        category_patterns[i] = pattern.new()
272✔
1384
    end
1385
    for icell in ip:cells() do
82✔
1386
        local cat = nbh:categorise(ip, icell)
80✔
1387
        category_patterns[cat]:insert(icell.x, icell.y)
80✔
1388
    end
1389
    return multipattern.new(category_patterns)
2✔
1390
end
1391

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

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

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

1508
--- Test components
1509
-- @section test
1510

1511
--- Returns the maximum allowed coordinate for spatial hashing.
1512
--
1513
-- @return maximum coordinate value (number).
1514
-- @usage
1515
-- local max_coord = pattern.get_max_coordinate()
1516
function pattern.get_max_coordinate()
1✔
1517
    return MAX_COORDINATE
1✔
1518
end
1519

1520
--- Tests the conversion between (x, y) coordinates and the spatial hash key.
1521
--
1522
-- @param x test x-coordinate (number).
1523
-- @param y test y-coordinate (number).
1524
-- @return boolean true if the conversion is correct, false otherwise.
1525
-- @usage
1526
-- local valid = pattern.test_coordinate_map(10, 20)
1527
function pattern.test_coordinate_map(x, y)
1✔
1528
    assert(type(x) == 'number' and type(y) == 'number',
20✔
1529
        "pattern.test_coordinate_map requires two numbers as arguments")
10✔
1530
    local key = coordinates_to_key(x, y)
10✔
1531
    local tx, ty = key_to_coordinates(key)
10✔
1532
    return (x == tx) and (y == ty)
10✔
1533
end
1534

1535
--- Prints the pattern within an optional domain, line-by-line.
1536
-- @param ip      pattern to render.
1537
-- @param char    (optional) single-character to draw “on” cells.
1538
-- @param domain  (optional) pattern defining the bounding box.
1539
-- @param printer (optional) function(line:string); defaults to io.write(line.."\n").
1540
function pattern.print(ip, char, domain, printer)
1✔
1541
    assert(getmetatable(ip) == pattern, "pattern.print requires a pattern as the first argument")
1✔
1542
    local onchar  = char or ip.onchar
1✔
1543
    local offchar = ip.offchar
1✔
1544
    domain        = domain or ip
1✔
1545
    assert(getmetatable(domain) == pattern, "pattern.print requires a pattern as the domain argument")
1✔
1546
    ensure_bbox(domain)
1✔
1547

NEW
1548
    local print_line = printer
×
1549
        or function(line) io.write(line .. "\n") end
1✔
1550

1551
    for y = domain.min.y, domain.max.y, 1 do
2✔
1552
        local chars = {}
1✔
1553
        for x = domain.min.x, domain.max.x, 1 do
2✔
1554
            chars[#chars + 1] = ip:has_cell(x, y) and onchar or offchar
1✔
1555
        end
1556
        print_line(table.concat(chars))
1✔
1557
    end
1558
end
1559

1560
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