Opt Art — Truchet

TruchetPatterns

DigitalArt.TruchetPatternsModule
TruchetPatterns

A catalogue of historical Truchet tile patterns, built on top of the pure geometry in TruchetTiles. This module does not depend on TruchetTiles — it works purely in terms of variant integers 1:4 — so that TruchetTiles can later depend on it without creating an import cycle.

At t = 0.5 each variant is a right triangle whose black half sits at one corner:

variantblack corner
1BL (bottom-left)
2TR (top-right)
3BR (bottom-right)
4TL (top-left)

This module provides two primitives that every catalogue entry is built from:

  • tile_pattern: repeat a small cell of variants periodically to fill a n × m grid.
  • mirror_quadrant: build a full 2p × 2q block from one p × q quadrant by reflecting it across both axes, remapping each tile's variant so the reflected copies point the right way.
DigitalArt.TruchetPatterns.mirror_quadrantMethod
mirror_quadrant(q::AbstractMatrix{<:Integer}) -> Matrix{Int}

Build a full 2p × 2q block from a p × q quadrant q by reflecting it across both axes. The top-left of the result is q itself, unreflected; the top-right is its left-right reflection; the bottom-left its up-down reflection; the bottom-right both (a 180 degree rotation).

Reflecting a variant is not the identity: each reflected copy has the corresponding variant map (_FLIP_LR, _FLIP_UD, _FLIP_BOTH) applied to its entries so the tiles point the right way.

DigitalArt.TruchetPatterns.pattern_cellMethod
pattern_cell(name) -> Matrix{Int}

The named pattern's fundamental cell — the block Bosch marks with a red box. Useful for inspecting a pattern, or for asking how many grid cells one repeat spans.

DigitalArt.TruchetPatterns.pattern_matrixMethod
pattern_matrix(name, n, m) -> Matrix{Int}

The n × m variant matrix for the named catalogue pattern, tiled periodically with the cell's [1,1] anchored at grid position (1,1).

DigitalArt.TruchetPatterns.tile_patternMethod
tile_pattern(cell::AbstractMatrix{<:Integer}, n::Integer, m::Integer) -> Matrix{Int}

Repeat cell periodically to fill an n × m grid of variants. For a p × q cell, result[i, j] = cell[mod1(i, p), mod1(j, q)]: cell [1,1] anchors at grid position (1,1), the top-left, and the pattern wraps.

Throws ArgumentError if n <= 0, m <= 0, cell is empty, or any entry of cell is not in 1:4.

TruchetTiles

DigitalArt.TruchetTilesModule
TruchetTiles

Pure geometry for the fixed and flexible Truchet tiles of Robert Bosch, Opt Art, Chapter 2. No rendering and no file I/O — see TruchetRender.

A flexible tile's dividing path runs from one corner to the opposite corner, bending at a movable vertex that slides along the other diagonal. The track is parameterized t ∈ [0,1], and AREA(white) == t exactly, so the parameter is the tile's brightness. t == 0.5 is the fixed (straight-diagonal) Truchet tile.

Brightness convention follows Bosch: 1 is white, 0 is black.

DigitalArt.TruchetTiles.TruchetTileType
TruchetTile(i, j, variant, t)

One tile at grid position (i, j). variant ∈ 1:4 selects among the four tiles of Bosch's Figure 2.6; t ∈ [0,1] is the position along the track and equals the tile's white area. t == 0.5 gives the fixed Truchet tile.

DigitalArt.TruchetTiles._variant_gridMethod
_variant_grid(variant, n, m, rng) -> Matrix{Int}

The n × m matrix of variants for variant. Built whole rather than per cell, so a pattern can be any callable (n, m) -> Matrix{Int} — the general contract — with the integer, :random, and :alternating cases as its degenerate forms.

:random draws in the same order the old per-cell comprehension did (i fastest), so a seeded rng gives byte-identical output to before.

DigitalArt.TruchetTiles.clamp_brightnessMethod
clamp_brightness(b; tone_range=(0.15, 0.85)) -> Float64

Restrict a brightness to tone_range. At the extremes of [0,1] the tile's vertex reaches a corner and the tile degenerates into a plain triangle, losing its Truchet character; clamping trades image contrast for tile legibility.

Throws ArgumentError if b is not finite (e.g. NaN from a missing pixel after downsampling an image), or if tone_range is not a valid sub-range of [0,1] — a tone_range outside [0,1] would let the vertex land outside the unit cell, bleeding the tile into its neighbours.

DigitalArt.TruchetTiles.intensity_matrixMethod
intensity_matrix(M) -> Function

Adapt a matrix of brightnesses to the (i, j) -> brightness interface truchet_grid expects. M[i, j] is read directly, so row 1 is the top row of the image as rendered.

DigitalArt.TruchetTiles.tile_polygonsMethod
tile_polygons(tile) -> (black, white)

The tile's two regions as vectors of unit-cell points. This is the single source of geometric truth: both white_area and the renderer consume it, so they cannot disagree about the tile's shape.

DigitalArt.TruchetTiles.truchet_gridMethod
truchet_grid(intensity, n, m; tone_range=(0.15,0.85), variant=:random, rng=Random.default_rng())

Build an n × m grid of tiles whose white areas match intensity.

intensity is anything callable as (i, j) -> brightness with brightness ∈ [0,1] and 1 = white — Bosch's convention, fed straight to t. Pass a matrix through intensity_matrix. Because AREA(white) == t, matching a target is an identity map, not a search.

variant chooses each tile's orientation and does not affect brightnessAREA(white) == t holds identically for all four, which is what makes the pattern free to vary while the image match stays exact. It may be:

valuemeaning
:random (default)an independent draw per cell
an integer 1:4the same variant everywhere
:alternatinga 2×2 checkerboard of variants 3 and 1
a catalogue name:truchet_a, :truchet_c, :truchet_d, :truchet_e, :douat_72 — the historical patterns of Bosch's Figures 2.2 and 2.3
a callableany (n, m) -> Matrix{Int} with entries in 1:4

A catalogue pattern tiles periodically from the top-left; a grid that is not a whole multiple of its period truncates at the right and bottom edges, which reads as ordinary cropping. Large-period patterns want a grid of at least ~4 periods, ideally 8 — see truchet_from_image for the fuller discussion.

DigitalArt.TruchetTiles.white_areaMethod
white_area(tile) -> Float64

The tile's white area, computed from its polygons. Equals tile.t by construction; computing it rather than returning t is what makes that identity a genuine test of the geometry.

TruchetColor

DigitalArt.TruchetColorModule
TruchetColor

Colour for the Truchet tiles of TruchetTiles: each tile inks in red, green, or blue rather than black, and its area matches that channel's value at its own pixel. At a fine enough grid the tiles fuse into perceived colour.

Sits beside TruchetTiles rather than inside it — channel assignment is a separate concern from tile geometry, the same reasoning that put TruchetPatterns in its own module.

The design's central invariant: channel and variant are orthogonal. Variant sets a tile's orientation, channel sets its ink, and neither constrains the other. Channel assignment therefore never consults the pattern, which is what lets pattern and colour vary independently — and what makes colour work for patterns of any period, including the single-tile truchet_a.

Additive only: ink is red/green/blue on a black background, so more ink is brighter. A subtractive CMY palette was tried and rejected — see the design doc.

DigitalArt.TruchetColor.ChannelTileType
ChannelTile(i, j, variant, t, channel)

One coloured tile at grid position (i, j). variant ∈ 1:4 and t ∈ [0,1] mean exactly what they mean for TruchetTiles.TruchetTilet is the white (uninked) area, so AREA == t still holds per tile. channel ∈ 1:3 selects the ink: 1 = red, 2 = green, 3 = blue.

A parallel type rather than a field added to TruchetTile, so the black-and-white path, its tests, and the golden-tested Rust port stay untouched.

DigitalArt.TruchetColor.channel_matrixMethod
channel_matrix(mode, n, m; variants=nothing) -> Matrix{Int}

The n × m matrix assigning each tile a channel in 1:3.

Assignment is per tile, not per pattern cell. Each entry is computed from that tile's own grid coordinates and never consults the pattern's period or cell boundaries.

moderule
:positionmod1(i + j, 3) — a diagonal RGB stripe
:variantVARIANT_TO_CHANNEL[variants[i,j]]; needs variants
a callableany (n, m) -> Matrix{Int} with entries in 1:3

:position is the default everywhere it is offered. It guarantees that a tile's neighbours along a row carry the other two channels, so the three inks are always adjacent and fusion works uniformly; and it is even to within one tile per channel on any grid (exactly 48/48/48 on a 12×12 douat_72 block).

:variant ties colour to the pattern's motifs instead. It throws an ArgumentError when the mapped channels number fewer than 3 — the condition that actually matters, checked after applying the map. That catches truchet_a (1 variant) and truchet_c (2), where three channels are unreachable.

DigitalArt.TruchetColor.truchet_color_gridMethod
truchet_color_grid(channel_value, n, m; tone_range=(0.15,0.85),
                   variant=:random, channels=:position, rng=Random.default_rng())

Build an n × m grid of ChannelTiles whose ink areas match channel_value.

channel_value is anything callable as (i, j, c) -> value with value ∈ [0,1], where c ∈ 1:3 is the channel. Each tile reads its own channel at its own position — there is no block averaging and no search, so the per-tile identity of the mono path is preserved exactly.

Polarity: a tile's inked area equals its channel value. Because the geometry's t is the white area, t = 1 - value. On the black background of a colour render, more ink is therefore brighter.

variant accepts everything TruchetTiles.truchet_grid accepts (an integer, :random, :alternating, a catalogue name, or a callable) and is forwarded to it unchanged. channels accepts everything channel_matrix accepts. The two are independent: variant sets orientation, channel sets ink.

tone_range clamps the channel value before inversion, so it means the same thing it does in the mono path — at the extremes the tile degenerates into a plain triangle and loses its Truchet character.

TruchetRender

DigitalArt.TruchetRenderModule
TruchetRender

Renders a Matrix{TruchetTile} (from TruchetTiles) with CairoMakie. Rendering and file I/O live here, separate from the geometry.

DigitalArt.TruchetRender.render_color_truchetMethod
render_color_truchet(grid; figure_size=(800,800), background=:black,
                     inks=(:red, :lime, :blue)) -> Figure

Draw a grid of ChannelTiles, each tile inked in its own channel's colour. grid[1, 1] is drawn at the top-left, matching render_truchet.

Additive by default: red/green/blue ink on black, so more ink is brighter and the three channels sum toward white. background and inks are exposed for experiment, but the additive pairing is the one the design is built around.

The green ink is :lime, not :green. The CSS/X11 colour named green is RGB(0, 0.502, 0) — half intensity — while red and blue are full. Using it makes the additive triple asymmetric and suppresses the green channel by half: measured on one fully-inked tile per channel, red and blue rendered at 0.1541 mean but green at 0.0774, and the green fur of the mandrill test image disappeared from the picture entirely. :lime is the full-intensity RGB(0, 1, 0) that an additive triple actually requires.

Colour needs a fine grid. The tiles only fuse into perceived colour when they are small; at coarse grids the result reads as a visible RGB mosaic. See TruchetImage.truchet_color_from_image for the sizing discussion.

One poly! call per channel — three in total, not one per tile. Makie treats each call as a separate plot object, and the per-tile cost is ~67x on a 60x60 grid, so the batching in render_truchet is load-bearing and is preserved here.

DigitalArt.TruchetRender.render_truchetMethod
render_truchet(grid; figure_size=(800,800), background=:white, ink=:black) -> Figure

Draw a grid of Truchet tiles. grid[1, 1] is drawn at the top-left, so row index increases downward, matching how the intensity matrix reads.

Only the black region of each tile is filled; the white region is the background showing through.

DigitalArt.TruchetRender.save_color_truchetMethod
save_color_truchet(path, grid; kwargs...) -> String

Render grid with render_color_truchet and save it to path. Extra keyword arguments are forwarded. Returns path.

DigitalArt.TruchetRender.save_truchetMethod
save_truchet(path, grid; kwargs...) -> String

Render grid and save it to path. Extra keyword arguments are passed to render_truchet. Returns path.

TruchetImage

DigitalArt.TruchetImageModule
TruchetImage

Turns image files into Truchet tile grids. Sits between the filesystem and TruchetTiles; knows nothing about rendering.

Three layers, each the one below plus one responsibility:

downsample          pure arithmetic, no I/O
load_intensity      file -> gray matrix -> downsample
truchet_from_image  the whole path -> Matrix{TruchetTile}

Because AREA(white) == t exactly, matching a target image is a per-cell identity map, not a search. This module is a thin adapter, and deliberately contains no image-approximation cleverness.

DigitalArt.TruchetImage._axis_weightsMethod

Overlap weights along one axis: _axis_weights(L, K)[k] lists the (pixel_index, weight) pairs contributing to cell k. Zero-weight pixels are omitted so the caller's inner loop stays tight.

DigitalArt.TruchetImage.channel_arrayMethod
channel_array(A) -> Function

Adapt an (n, m, 3) array to the (i, j, c) -> value interface truchet_color_grid expects. A[i, j, c] is read directly, so row 1 is the top row of the image as rendered.

DigitalArt.TruchetImage.downsampleMethod
downsample(gray, n, m) -> Matrix{Float64}

Average gray into an n × m grid over exact fractional cell boundaries.

Output row i covers the source-pixel interval ((i-1)·H/n, i·H/n], columns likewise. A source pixel straddling a boundary is weighted by the fraction of it falling inside the cell, so every pixel contributes with total weight 1 and no edge data is dropped, whatever the divisibility. Integer-step block averaging silently discards trailing rows and columns; this does not.

Throws ArgumentError if the grid is empty or exceeds the source in either dimension — upsampling is not averaging, and duplicating cells while calling the result a downsample would be a lie.

DigitalArt.TruchetImage.grid_reportMethod
grid_report(path, n) -> NamedTuple
grid_report(path, n, m) -> NamedTuple

What a grid size actually costs, in numbers rather than by eye. Returns (; source, grid, pixels_per_tile, tiles) where source is the image's pixel dimensions, grid the resulting tile grid, and pixels_per_tile the source region each tile averages.

pixels_per_tile is the number the preview cannot show you: it says how much detail is being discarded per tile. A tile averaging 168×162 pixels has thrown away everything smaller than a third of the frame, and no rendering choice recovers it.

julia> grid_report("photo.png", 24)
(source = (4032, 2268), grid = (24, 14), pixels_per_tile = (168.0, 162.0), tiles = 336)
DigitalArt.TruchetImage.load_channelsMethod
load_channels(path, n, m) -> Array{Float64,3}

Load the image at path and downsample it to an (n, m, 3) array of red, green, and blue values in [0,1]. Row 1 is the top of the image, matching load_intensity and the renderers.

Each channel goes through the same downsample the mono path uses — its fractional-boundary correctness is already tested, and a second implementation would be a second thing to get wrong.

Throws ArgumentError if path does not exist. A file that exists but is not a readable image raises FileIO's own error, deliberately unwrapped.

DigitalArt.TruchetImage.load_intensityMethod
load_intensity(path, n, m) -> Matrix{Float64}

Load the image at path, convert it to grayscale, and downsample it to n × m brightnesses in [0,1] with 1 = white.

Row 1 is the top of the image, matching intensity_matrix and render_truchet. RGB and alpha-channel images are handled rather than rejected.

Throws ArgumentError if path does not exist. A file that exists but is not a readable image raises FileIO's own error, deliberately unwrapped — its format details are the actual diagnostic.

DigitalArt.TruchetImage.preview_colorMethod
preview_color(path, n; kwargs...) -> Matrix{RGB{Float64}}
preview_color(path, n, m) -> Matrix{RGB{Float64}}

The downsampled colour as an image, for choosing a grid size before committing to a tile render. The colour analogue of preview_intensity.

Each pixel is one future tile. Displaying this in a notebook is near-instant where the tile render is not, and the grid/pattern/colour interaction is hard enough to predict that previewing is worth the step.

The two-argument form preserves the aspect ratio with n as the long edge, matching truchet_color_from_image.

DigitalArt.TruchetImage.preview_intensityMethod
preview_intensity(path, n; kwargs...) -> Matrix{Gray{Float64}}
preview_intensity(path, n, m) -> Matrix{Gray{Float64}}

The downsampled brightnesses as a grayscale image, for choosing a grid size before committing to a tile render.

Each pixel of the result is one future tile. That is the whole point: a grid of n × m tiles carries exactly n × m brightness values, so if the subject is not recognisable here it will not be recognisable in tiles either. Displaying this in a notebook is near-instant, where the tile render is not.

The two-argument form preserves the aspect ratio with n as the long edge, matching truchet_from_image.

Choosing a grid size

Two things constrain it, and neither is the source image's pixel dimensions — a 12-megapixel photo and a small thumbnail give identical results at the same grid size.

  • Subject scale. What matters is how many tiles span the subject, not the frame. A tightly-cropped portrait reads at 48; a subject occupying a third of the frame may need 144.
  • Pattern period. A patterned grid also needs enough repeats of the pattern. Below about 4 periods across, a large-period pattern like :douat_72 (a 12×12 block) dominates the picture rather than texturing it; by 8 periods it reads as texture. Patterns with 1×1 or 2×2 cells have no such threshold.
DigitalArt.TruchetImage.truchet_color_from_imageMethod
truchet_color_from_image(path, n; kwargs...) -> Matrix{ChannelTile}
truchet_color_from_image(path, n, m; kwargs...) -> Matrix{ChannelTile}

Build a grid of coloured Truchet tiles approximating the image at path. Each tile inks in red, green, or blue, with its ink area equal to that channel's value at that tile's own pixel.

The two-argument form preserves the aspect ratio: n is the long edge of the image. The three-argument form takes both dimensions and distorts the aspect ratio if they do not match the source. Neither upscales.

tone_range, variant, channels, and rng are forwarded to truchet_color_grid unchanged.

Choosing n

The single most important parameter. Colour renders want grids at least as large as a mono render of the same image, and preferably larger, for two compounding reasons:

  • Each tile carries one channel instead of full tone, so effective colour detail is roughly a third of the grid.
  • A patterned grid still needs its ~8 periods for the pattern to read as texture rather than compete with the picture.

For douat_72 (a 12×12 block), about 30 periods across — roughly 360 tiles on the long edge — is where a tightly-cropped portrait reads well. Below about 15 periods the pattern dominates and the subject is lost, in colour and mono alike. Use preview_color to judge a size in a fraction of the render time.

What this looks like

Additive colour on black: a mid-grey pixel lights each of its three tiles to half area, so only half the canvas is inked and that ink is split across three narrow-band primaries. The result is darker and more muted than the equivalent mono render, which puts a large black shape on bright white paper. No arrangement of R/G/B tiles reaches the pure black or pure white a mono tile does. This is a property of additive mixing, not a defect: the output reads as a dark, CRT-phosphor mosaic rather than as photographic colour.

DigitalArt.TruchetImage.truchet_from_imageMethod
truchet_from_image(path, n; kwargs...) -> Matrix{TruchetTile}
truchet_from_image(path, n, m; kwargs...) -> Matrix{TruchetTile}

Build a grid of flexible Truchet tiles approximating the image at path.

The two-argument form preserves the aspect ratio: n is the long edge of the image, and the short edge follows from its shape. A 640×480 landscape photo at n = 64 gives a 48×64 grid; a 480×640 portrait gives 64×48. The returned grid's row count therefore varies with the input's orientation.

The three-argument form takes both dimensions and distorts the aspect ratio if they do not match the source.

Neither form upscales: a grid finer than the image is an ArgumentError.

tone_range, variant, and rng are forwarded to truchet_grid unchanged.

Choosing n

There is no automatic right answer — it depends on the picture. Use preview_intensity to see the downsampled brightnesses in a fraction of the time a tile render takes, then commit.

The grid is the resolution: n × m tiles carry exactly n × m brightness values, so the result is an n × m-pixel image drawn with fancy dots. The source photograph's own pixel dimensions do not enter into it beyond setting the upper bound.

What matters is how many tiles span the subject, not the frame. A subject occupying a third of the frame needs roughly three times the grid a tightly-cropped one does.

With a variant pattern there is a second constraint: the grid needs enough repeats of the pattern's period. Below about 4 periods across, a large-period pattern such as :douat_72 (a 12×12 block) competes with the picture instead of texturing it; by 8 periods it recedes into texture. A grid that is not a whole multiple of the period simply truncates the pattern at the right and bottom edges, which reads as ordinary cropping and needs no correction.