I decided to test out some of the scripting capabilities of Typst and see if it could generate arbitrary Lozenge tilings. The answer is of course yes, and even quite large ones. This one did take 12 seconds to compile, but nonetheless, the ease of use was fantastic.
That is so cool.
Will you share the code that made it, please?
Assuming I donβt get bogged down in something else, eventually. I was considering making this my first package contribution to the Typst universe, though I donβt use Github, so itβs going to be an interesting learning experience.
Issue right now is that the docstring comments are a mess, mixing up very detailed algorithm discussion with what should actually be in there. I need to make a proper manual document before I can think about uploading.
Okay, having taken a proper look at just what goes into getting a package into compliance for the Typst Universe, Iβm probably not gonna upload this myself. Itβs simply going to be too much. Iβll upload the code I wrote here for anyone to use. I do still plan to write up a PDF explaining the algorithm I used, though hopefully it is also sufficiently described in the comments of the code.
AI disclosure: I used Claude Sonnet 4.6 to write the entirety of generate_lozenge_tiling and plot_lozenge_tiling, and to check for some logic bugs that I had manually written into valid_tiling, but the rest, including the main coordinate algorithms, were manually written by me, in part because AI was just terrible at writing it up when I tried.
#import "@preview/cetz:0.5.2" as cetz
#import "@preview/suiji:0.5.1" as suiji
#import "@preview/lilaq:0.6.0" as lq // not necessary, I just use this for the colors module
/// Checks that the given array of binary digits `lozenge` represents a valid tiling of a regular hexagon, and if so, returns the arrays of lozenge type and height data. `silent` controls whether to suppress panic output for invalid tilings.
///
/// ```example
/// #let lozenge = ("111000", "111000", "000111")
/// #valid_tiling(lozenge, silent: false)
/// ```
///
/// -> array
#let valid_tiling(
/// The array of binary digits representing the tiling.
/// -> array
lozenge,
/// Whether to suppress panic output for invalid tilings.
/// -> boolean
silent: false,
) = {
let N = lozenge.len()
// Step 1: Check that the input is an array of N strings of binary digits of length 2*N
for row in lozenge {
if not row.len() == 2 * N {
if silent { return none } else {
assert(false, message: "row has length " + str(row.len()) + ", expected " + str(2 * N))
}
}
for d in row.clusters() {
if not (d == "0" or d == "1") {
if silent { return none } else { assert(false, message: "invalid digit " + str(d) + ", expected 0 or 1") }
}
}
}
// Step 2: Each row must have equal number of 0s and 1s
for row in lozenge {
let ones = row.clusters().filter(d => d == "1").len()
if not ones == N {
if silent { return none } else { assert(false, message: "row has " + str(ones) + " ones, expected " + str(N)) }
}
}
// ==========================================================
// Step 3a: compute cumulative sums for each row (0 -> -1, 1 -> +1)
// ==========================================================
// initialize array to store cumulative sums for each row; thus, cum_sums will be an array of N arrays of 2*N integers
let cum_sums = ()
// loop over each row
for row in lozenge {
// initialize accumulator and row sums array for this row
let acc = 0
let row_sums = ()
// loop over each digit in the row, updating accumulator and row sums
for d in row.clusters() {
acc += if d == "1" { 1 } else { -1 }
row_sums.push(acc)
}
// push row sums array to cum_sums after processing this row
cum_sums.push(row_sums)
}
// ==========================================================
// Step 3b: apply row shift and verify no consecutive rows share a height at any position
// ==========================================================
// loop over each pair of consecutive rows, starting with rows 0 and 1, and ending with rows N-2 and N-1
for i in range(N - 1) {
// loop over each position in the row, verifying no height overlap between the two rows
for j in range(N * 2) {
// extract height above and below for this position from the cum_sums array and apply row shift
let h_above = cum_sums.at(i).at(j) - i
let h_below = cum_sums.at(i + 1).at(j) - (i + 1)
// verify that the height above and below are not equal, indicating no overlap between the two rows
if not h_above > h_below {
if silent { return none } else {
assert(
false,
message: "rows "
+ str(i)
+ " and "
+ str(i + 1)
+ " overlap at position "
+ str(j)
+ " (height "
+ str(h_above)
+ " vs. height "
+ str(h_below)
+ ")",
)
}
}
}
}
// ==========================================================
// Build output: array of rows, each row is an array of (type, height) pairs
// ==========================================================
// initialize the result array to store the output rows
let result = ()
// iterate over each row in the lozenge, augmenting each row with an index, so as to be able to grab the associated height from the cum_sums array
for (i, row) in lozenge.enumerate(start: 0) {
// initialize the row result array to store the (type, height) pairs for this row
let row_result = ()
// clusters splits row binary string into an array of "1"s and "0"s, enumerating each cluster with an index
// so as to associate each cluster with its height in the cum_sums array
for (j, d) in row.clusters().enumerate(start: 0) {
row_result.push((if d == "1" { 1 } else { 0 }, cum_sums.at(i).at(j)))
}
result.push(row_result)
}
return result
}
/// Reads a .loz file at `file_path` and performs valid_tiling on the contents.
#let read_loz_file(
/// The path to the .loz file
/// -> path | string
file_path,
) = {
let content = read(path(file_path))
// split the content string into an array of lines
let lines = content.split("\n")
// Feed array of lines to valid_tiling
return valid_tiling(lines)
}
/// Generates a random lozenge tiling of a regular hexagon with `N` lozenges per side via Markov chain Monte Carlo (MCMC).
///
/// Starts from the "empty room" initial state:
/// rows 0 β¦ N-2 : "111β¦000" (N ones, then N zeros)
/// row N-1 : "000β¦111" (N zeros, then N ones)
///
/// Then performs `steps` accepted adjacent-swap moves and returns the resulting tiling as output suitable for use with @compute_lozenge_coordinates. As a safety mechanism, and to work around Typst's `while` loop restrictions, set a maximum number of attempted steps to perform before giving up `max_steps`, even if the desired number of successful flips isn't reached.
///
/// A higher `steps` value produces tilings further from the initial configuration and closer to the uniform distribution over all valid tilings; a lower value keeps results near the "empty room" state.
///
/// ```example
/// #generate_lozenge_tiling(4, 10000, seed: 10)
/// ```
///
/// -> array
#let generate_lozenge_tiling(
/// Number of lozenges along each side of the hexagon.
/// -> int
N,
/// Maximum number of attempted flips to perform
/// ->
max_steps: 200000,
/// Number of successful (state-changing) flips to aim for before returning.
/// -> int
steps,
/// Seed for the random number generator.
/// -> int
seed: 42,
) = {
let row-len = 2 * N
// ββ Build the "empty room" initial state ββββββββββββββββββββββββββββββ
// `rows` : N arrays of 0/1 integers (converted to strings at the end)
// `heights` : N arrays of shifted cumulative heights, the kind used in validating the tilings
let rows = ()
let heights = ()
for i in range(N) {
// Rows 0β¦N-2 are "111β¦000"; the last row is "000β¦111".
let row = range(row-len).map(j => if i < N - 1 {
if j < N { 1 } else { 0 }
} else {
if j < N { 0 } else { 1 }
})
rows.push(row)
// Precompute shifted heights for this row.
let h-row = ()
let acc = 0
for j in range(row-len) {
acc += if row.at(j) == 1 { 1 } else { -1 }
h-row.push(acc - i)
}
heights.push(h-row)
}
// ββ MCMC loop βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
let rng = suiji.gen-rng-f(seed)
let successful = 0
// Use for loop statement to avoid Typst's while loop restrictions
for _ in range(max_steps) {
// Pick a random row i β [0, N-1] and column j β [0, 2N-2].
// j is capped at 2N-2 so that j+1 always points to a valid element.
let i = 0
let j = 0
(rng, i) = suiji.integers-f(rng, low: 0, high: N)
(rng, j) = suiji.integers-f(rng, low: 0, high: row-len - 1)
let b-j = rows.at(i).at(j)
let b-j1 = rows.at(i).at(j + 1)
// ββ Discard if bits are identical (no swap is geometrically possible) β
if b-j == b-j1 { continue }
// ββ Proposed new shifted height at (i, j) βββββββββββββββββββββββββββββ
// "10" β "01" : cumsum drops by 2 β h_new = h_old β 2
// "01" β "10" : cumsum rises by 2 β h_new = h_old + 2
let h-old = heights.at(i).at(j)
let h-new = if b-j == 1 { h-old - 2 } else { h-old + 2 }
// ββ Upper-row height constraint (skip check for the topmost row) ββββββ
// heights[i-1][j] > h_new must hold
if i > 0 {
if heights.at(i - 1).at(j) <= h-new { continue }
}
// ββ Lower-row height constraint (skip check for the bottommost row) βββ
// h_new > heights[i+1][j] must hold
if i < N - 1 {
if h-new <= heights.at(i + 1).at(j) { continue }
}
// ββ Commit: swap the two bits and update the single changed height βββββ
// Replace rows[i][j] with b-j1 and rows[i][j+1] with b-j.
let row-i = rows.at(i)
let new-row = row-i.slice(0, j) + (b-j1, b-j) + row-i.slice(j + 2)
rows = rows.slice(0, i) + (new-row,) + rows.slice(i + 1)
// Replace heights[i][j] with h-new; every other height is unchanged.
let h-row-i = heights.at(i)
let new-h-row = h-row-i.slice(0, j) + (h-new,) + h-row-i.slice(j + 1)
heights = heights.slice(0, i) + (new-h-row,) + heights.slice(i + 1)
// Increment the successful steps counter and break if we've reached the desired number of steps.
successful += 1
if successful == steps { break }
}
// Undo shifts of heights before merging rows and heights, adding +j for row j for rows 0 to N-1.
let heights = heights
.enumerate()
.map(pair => {
let (i, row) = pair
row.map(x => x + i)
})
// ββ Generate output usable by compute_lozenge_coordinates by merging the rows and heights arrays into a single array of row arrays.
rows.zip(heights).map(pair => pair.at(0).zip(pair.at(1)))
}
/// Given the output of valid_tiling, stored in `tiling`, computes the actual coordinates of the lozenges in the tiling for plotting, with length `L` for each side of a lozenge.
///
/// -> array
#let compute_lozenge_coordinates(
/// The length of the side of a single lozenge.
/// -> float
L: 1,
/// The lozenge tiling representation output by valid_tiling to be processed.
/// -> array
tiling,
) = {
// Get the number of rows N from the tiling
let N = tiling.len()
// ==========================================================
// Generate coordinates for the type 0 and type 1 lozenges
// ==========================================================
// Initialize compact values
let c = calc.cos(calc.pi / 6)
let s = calc.sin(calc.pi / 6)
// Initialize the output array. For now, we will have it have the structure of an array of arrays of arrays
// The outermost array will store the rows of the lozenge tiling, each of which is an array of lozenges
let type01_coords = ()
// Loop over each row of the lozenge tiling
for (i, row) in tiling.enumerate(start: 0) {
// Initialize the current row of lozenges
let row_coords = ()
// Initialize the first lozenge in the row with its pre-determined coordinates
let row_starting_lozenge = (
// Lozenge type
row.at(0).at(0),
// Lozenge height without the shift
row.at(0).at(1),
// Vertices' coordinates of the lozenge
if row.at(0).at(0) == 0 {
((0, -i * L), (0, -(i + 1) * L), (L * c, -(i + 1) * L - L * s), (L * c, -i * L - s * L))
} else if row.at(0).at(0) == 1 {
((0, -i * L), (0, -(i + 1) * L), (L * c, -(i + 1) * L + L * s), (L * c, -i * L + s * L))
},
)
// Add the row's starting lozenge to the current row of lozenges
row_coords.push(row_starting_lozenge)
// With the row starting lozenge in place, we remove the first element from the row
row = row.slice(1)
// Loop over the remaining lozenges in the row
// Each lozenge is an array of two elements: the lozenge type and the lozenge height
for lozenge in row {
// ===============================
// Compute the coordinates for the current lozenge
// ===============================
// To start, pull the first two vertices of the current lozenge as the 4th and 3rd vertices of the prior lozenge
// 1st .at gets the last element of type01_coords (the previous lozenge)
// 2nd .at gets the second element of the previous lozenge (the vertex coordinates instead of the lozenge type)
// 3rd .at gets the 4th and 3rd vertices of the current prior lozenge, so coord1 and coord2 are length 2 arrays
let coord1 = row_coords.last().at(2).at(3)
let coord2 = row_coords.last().at(2).at(2)
// Initialize coord3 and coord4
let coord3 = (0, 0)
let coord4 = (0, 0)
// With the first two vertices in hand, we can compute the remaining vertices of the current lozenge using the current lozenge type and relative displacements
if lozenge.at(0) == 0 {
coord3 = (coord2.at(0) + L * c, coord2.at(1) - L * s)
coord4 = (coord3.at(0), coord3.at(1) + L)
} else if lozenge.at(0) == 1 {
coord3 = (coord2.at(0) + L * c, coord2.at(1) + L * s)
coord4 = (coord3.at(0), coord3.at(1) + L)
}
// Store the computed vertices in the row of lozenges array for use in the next iteration
row_coords.push((lozenge.at(0), lozenge.at(1), (coord1, coord2, coord3, coord4)))
}
// With all the lozenges in the current row computed, add the row to the output array
type01_coords.push(row_coords)
}
// The prior loop computes all the coordinates for the type 0 and type 1 lozenge vertices
// We now turn to computing the coordinates for the type 2 lozenges
// To do this, we use the previously explained method of using the type 0 lozenges as anchors
// Initialize the type 2 lozenge coordinates array, which will be an array of an integer and arrays, with the outermost array collecting all the type 2 lozenges, and the individual arrays collecting the type of the lozenge (2) and the coordinates for each lozenge
let type2_coords = ()
// We start with the type 2 lozenges above the 0th row. We first filter out all the type 1 lozenges in the 01_coords array, in every row of lozenges
let type0_coords = type01_coords.map(lozenge_row => lozenge_row.filter(lozenge => lozenge.at(0) == 0))
// Iterate over the remaining type 0 lozenges of row 0 to compute their type 2 lozenge coordinates
// Here, lozenge is now an array of the lozenge's type (0), its height, and its vertices' coordinates
for (i, lozenge) in type0_coords.at(0).enumerate(start: 0) {
// Compute the height difference = number of type 2 lozenges above this type 0 lozenge, between the type 0 lozenge and the upper base of (N - 1, N - 2, 0)
let num_type2_above = N - 1 - i - lozenge.at(1)
// If num_type2_above is greater than 0, we have type 2 lozenges above this type 0 lozenge, so we compute their coordinates
if (num_type2_above > 0) {
// The first type 2 lozenge is a special initial case, so we do this one manually
// The first two vertices of the type 2 lozenge are the same as the type 0 lozenge's 1st and 4th vertices
// The third and 4th vertices are then computed using relative displacements
type2_coords.push((
2,
(
lozenge.at(2).at(0),
lozenge.at(2).at(3),
(lozenge.at(2).at(0).at(0) + 2 * L * c, lozenge.at(2).at(0).at(1)),
(lozenge.at(2).at(3).at(0), lozenge.at(2).at(3).at(1) + 2 * L * s),
),
))
// With the first type 2 lozenge computed, we can compute the remaining type 2 lozenges using a for loop, but only if num_type2_above is greater than 1
if (num_type2_above > 1) {
for j in range(num_type2_above - 1) {
// The 1st and 2nd vertices of the current type 2 lozenge are the same as the previous type 2 lozenge's 4th and 3rd vertices
let coord1 = type2_coords.last().at(1).at(3)
let coord2 = type2_coords.last().at(1).at(2)
// With the first two vertices in hand, we can compute the remaining vertices of the current lozenge using relative displacements
type2_coords.push((
2,
(
coord1,
coord2,
(coord1.at(0) + 2 * L * c, coord1.at(1)),
(coord2.at(0), coord2.at(1) + 2 * L * s),
),
))
}
}
}
}
// With the type 2 lozenges computed for above row 0, we next compute the type 2 lozenges between two rows using a similar method, starting with computing the number of type 2 lozenges between the two rows
// loop over all rows
for (i, lozenge_row) in type0_coords.slice(1).enumerate(start: 1) {
// loop over all lozenges in the current row
for (j, lozenge) in lozenge_row.enumerate(start: 0) {
// Compute height difference = number of type 2 lozenges between the row i, jth type 0 lozenge and the row i-1, jth type 0 lozenge
// first .at() of type0_coords gives the row
// second .at() of type0_coords gives the jth type 0 lozenge in that row
// third .at() of type0_coords gives the height of the lozenge
let num_type2_above = type0_coords.at(i - 1).at(j).at(1) - type0_coords.at(i).at(j).at(1)
// As before, if num_type2_above is > 0, we manually instantiate the first type 2 lozenge and then loop over the remaining type 2 lozenges between the two rows
if num_type2_above > 0 {
type2_coords.push((
2,
// The 1st and 2nd vertices' coordinates are the same as the corresponding type 0 lozenge's 1st and 4th vertices
(
lozenge.at(2).at(0),
lozenge.at(2).at(3),
(lozenge.at(2).at(0).at(0) + 2 * L * c, lozenge.at(2).at(0).at(1)),
(lozenge.at(2).at(3).at(0), lozenge.at(2).at(3).at(1) + 2 * L * s),
),
))
// If num_type2_above is > 1, we can compute the remaining type 2 lozenges using a for loop, but only if num_type2_above is greater than 1
if (num_type2_above > 1) {
for j in range(num_type2_above - 1) {
// The 1st and 2nd vertices of the current type 2 lozenge are the same as the previous type 2 lozenge's 4th and 3rd vertices
let coord1 = type2_coords.last().at(1).at(3)
let coord2 = type2_coords.last().at(1).at(2)
// With the first two vertices in hand, we can compute the remaining vertices of the current lozenge using relative displacements
type2_coords.push((
2,
(
coord1,
coord2,
(coord1.at(0) + 2 * L * c, coord1.at(1)),
(coord2.at(0), coord2.at(1) + 2 * L * s),
),
))
}
}
}
}
}
// With the type 2 lozenges computed for between any two rows, we can now compute the type 2 lozenges below the last row
// Iterate over the type 0 lozenges of row N - 1 to compute the type 2 lozenge coordinates
// Here, lozenge is now an array of the lozenge's type (0), its height, and its vertices' coordinates
for (i, lozenge) in type0_coords.at(N - 1).enumerate(start: 0) {
// Compute the height difference = number of type 2 lozenges below this type 0 lozenge, between the type 0 lozenge and the upper base of (N - 1, N - 2, 0)
let num_type2_below = lozenge.at(1) + (i + 1)
// If num_type2_below is greater than 0, we have type 2 lozenges below this type 0 lozenge, so we compute their coordinates
if (num_type2_below > 0) {
// The first type 2 lozenge is a special initial case, so we do this one manually
// The first two vertices of the type 2 lozenge are fixed as (0 + i*L*cos(pi/6), -N*L - i*L*sin(pi/6)) for i and i+1
// The third and 4th vertices are then computed using relative displacements
type2_coords.push((
2,
(
(i * L * c, -N * L - i * L * s),
((i + 1) * L * c, -N * L - (i + 1) * L * s),
((i + 2) * L * c, -N * L - i * L * s),
((i + 1) * L * c, -N * L - (i - 1) * L * s),
),
))
// With the first type 2 lozenge computed, we can compute the remaining type 2 lozenges using a for loop, but only if num_type2_above is greater than 1
if (num_type2_below > 1) {
for j in range(num_type2_below - 1) {
// The 1st and 2nd vertices of the current type 2 lozenge are the same as the previous type 2 lozenge's 4th and 3rd vertices
let coord1 = type2_coords.last().at(1).at(3)
let coord2 = type2_coords.last().at(1).at(2)
// With the first two vertices in hand, we can compute the remaining vertices of the current lozenge using relative displacements
type2_coords.push((
2,
(
coord1,
coord2,
(coord1.at(0) + 2 * L * c, coord1.at(1)),
(coord2.at(0), coord2.at(1) + 2 * L * s),
),
))
}
}
}
}
// With this, all the type 2 lozenges have been computed, so we now clean up the data. Firstly, we no longer need the different rows separated for type01_coords, so we can flatten it into a single list of lozenges.
let type01_coords_flat = type01_coords.sum(default: ())
// Next, we no longer need the height data for the type 0 and 1 lozenges in type01_coords, so we prune those off.
let type01_coords_cleaned = type01_coords_flat.map(lozenge => (lozenge.at(0),) + lozenge.slice(2))
// Finally, we concatenate the type 2 lozenges onto the end of type01_coords_cleaned
let type_012_lozenges = type01_coords_cleaned + type2_coords
// NOTE: The final result is a list of lozenges, where each lozenge is represented as an array of its type (0, 1, or 2) and its coordinates.
return type_012_lozenges
}
/// Plots a lozenge tiling on a CeTZ canvas using the output of `compute_lozenge_coordinates(...)` in `tiling`.
///
/// -> content
#let plot_lozenge_tiling(
/// The lozenge tiling to plot, as returned by `compute_lozenge_coordinates(...)`.
/// -> array
tiling,
/// Fill colors for lozenge types 0, 1, and 2 respectively.
/// -> array
colors: (blue.lighten(30%), red.lighten(30%), green.lighten(30%)),
/// Whether to draw a border around each lozenge globally.
/// -> bool
border: true,
/// Stroke color for lozenge borders (when `border` is `true`).
/// -> color
border-color: black,
/// Stroke thickness for lozenge borders (when `border` is `true`).
/// -> length
border-width: 0.5pt,
/// Whether to render each lozenge's index (its position in `tiling`) at its center.
/// -> bool
show-index: false,
/// Text size for the index labels rendered when `show-index` is `true`.
/// -> length
index-size: 6pt,
/// Per-lozenge style overrides, keyed by lozenge index.
/// Keys may be integers or their string equivalents.
/// Each value is a dictionary accepting any subset of:
/// - `color` (color) β override fill color for this lozenge
/// - `border` (bool) β override border visibility for this lozenge
/// - `border-color` (color) β override border stroke color for this lozenge
/// - `border-width` (length) β override border stroke width for this lozenge
///
/// Example:
/// ```typst
/// overrides: (
/// "0": (color: red),
/// "3": (border: false),
/// "7": (color: yellow, border-color: orange, border-width: 1pt),
/// )
/// ```
/// -> dictionary
overrides: (:),
/// Debug level for the CetZ canvas.
/// -> bool
debug-stat: false,
/// Content to insert on the CetZ canvas after the lozenge tiling is drawn.
/// -> content
overlay: none,
) = {
cetz.canvas(debug: debug-stat, {
import cetz.draw: *
for (idx, lozenge) in tiling.enumerate(start: 0) {
let ltype = lozenge.at(0)
let verts = lozenge.at(1)
// ββ Resolve style, starting from global defaults βββββββββββββββββββββββ
let fill-color = colors.at(ltype)
let show-border = border
let s-color = border-color
let s-width = border-width
// ββ Apply per-lozenge overrides ββββββββββββββββββββββββββββββββββββββββ
// Typst stores integer dict keys as their string representation,
// so str(idx) matches both (0: ...) and ("0": ...) in the caller.
let ov-key = str(idx)
if ov-key in overrides {
let ov = overrides.at(ov-key)
if "color" in ov { fill-color = ov.at("color") }
if "border" in ov { show-border = ov.at("border") }
if "border-color" in ov { s-color = ov.at("border-color") }
if "border-width" in ov { s-width = ov.at("border-width") }
}
// ββ Build stroke value βββββββββββββββββββββββββββββββββββββββββββββββββ
let stroke-style = if show-border {
(paint: s-color, thickness: s-width)
} else {
none
}
// ββ Draw the lozenge as a closed quadrilateral βββββββββββββββββββββββββ
line(
verts.at(0),
verts.at(1),
verts.at(2),
verts.at(3),
close: true,
fill: fill-color,
stroke: stroke-style,
)
// ββ Optionally label each lozenge with its index βββββββββββββββββββββββ
if show-index {
// Geometric centroid = average of the four vertices
let cx = (verts.at(0).at(0) + verts.at(1).at(0) + verts.at(2).at(0) + verts.at(3).at(0)) / 4
let cy = (verts.at(0).at(1) + verts.at(1).at(1) + verts.at(2).at(1) + verts.at(3).at(1)) / 4
content(
(cx, cy),
text(size: index-size, str(idx)),
anchor: "center",
)
}
// Insert user-provided content on the canvas
if overlay != none { overlay }
}
})
}
So I naively copied the above code into Lozenge.typ and compiled it without error. Alas a small PDF file displaying only a blank page resulted.
How does one run this code?
Ah, I should have clarified. This is the content of what should be the lib.typ file for if this goes into a package, so I didnβt place any running examples there. My understanding was that lib.typ shouldnβt contain any examples, except maybe in the docstrings if you want to work with tidy.
If you want to run examples in the same file as the actual functions, here is one to start with:
#let tile_data = valid_tiling((
"1001101100",
"1001101010",
"0011101001",
"0011101001",
"0010110101"
))
#plot_lozenge_tiling(compute_lozenge_coordinates(tile_data))
