dInfinity

Tables

Design: the table picker is option 1u of the clickable design (design/); 9j adds an installed set’s table and 8a pins one to a saved-roll group.

The table is the dice tray: the box the dice tumble in. Two things about it are fixed and one is exchangeable.

Geometry

Simulation units are millimetres.

Property Value
Long side 240 mm, always (a real dice tray, regardless of phone size)
Short side 240 mm × screen aspect ratio, clamped to 0.40–0.75 (Pixel 10a: 20:9 → ~108 mm)
Wall height 60 mm as drawn; the collision box is closed to the ceiling at 200 mm
Inner corner radius 12 mm (dice do not wedge into sharp corners); a fillet of six flat segments to the quarter, the same outline the mesh draws
Floor friction / restitution from the table look, clamped (see below)

The two heights are not a contradiction. Sixty millimetres is the rim the renderer draws — the wall you can see. The collision walls run the full two hundred up to the ceiling, because “dice cannot leave the table no matter how hard the phone is shaken” is not true of a box with a lid two hundred millimetres up and sides only sixty: a die thrown hard leaves through the gap and never comes back. The rounded corners run the full height too. They were once only as tall as the rim, on the reasoning that the rim is as far up as a die can wedge into anything — which is true, and beside the point: a post that stops at sixty has a top, and four horizontal ledges sixty millimetres up inside the tray are four places a die can come to rest in mid-air. It took a phone to see it, and what it looked like was a die floating with its shadow on the floor beneath it.

A rounded corner is a fillet, and the solid is exactly what is drawn. In each corner the collision shape is the solid between the two walls and a quarter circle of the corner radius tangent to both, built from six flat wedges at the same six segments to the quarter the tray mesh draws (TrayMesh.CORNER_SEGMENTS). It used to be a whole cylinder standing on that circle’s centre — not a rounded corner but a post 24 mm across, bulging 17 mm out of the corner along the diagonal onto floor the renderer draws as open. Dice leaned on it in mid-air, and a die leaning on a round post and a neighbour has one contact on a curve and nothing to settle into: the two 60d20 rolls in 10,000 that never came to rest were both doing that (docs/physics-and-rendering.md, “Why a die could rock for ever”).

Orientation follows the phone: portrait phone → portrait table. The roll screen then holds whatever shape it opened in, because the tray is the screen and a quarter turn rebuilds the table — a different shape, a different capacity, the camera reframed. That is the right answer for somebody who meant to turn it and an unwelcome surprise for somebody who is shaking it, which is most of the time on that screen. Held to the shape it was opened in rather than to portrait: a player who opened the app in landscape meant it. The rest of the app turns as it likes.

A half turn is allowed, and a quarter turn is not. Turning the phone end over end gives back the same table — same aspect ratio, same capacity, same camera — so none of the reasoning above applies to it, and refusing it costs something real: the display’s rotation is how a shake knows which way the hand went, so a screen pinned to the rotation it opened at reports the phone as upright while it is being shaken upside down, and the dice pool at the end away from the hand (docs/physics-and-rendering.md, “Shake input”). The screen asks for its shape either way up, which also leaves a player who has locked rotation system-wide with the phone held still, as they asked.

The physical size of the phone’s screen is deliberately not used to scale the table. A 6.3” and a 6.9” phone get the same table; only the aspect ratio differs. This keeps rolls comparable and keeps the capacity numbers stable across devices.

Capacity rule

Before any physics body is created, the roll is checked against the table:

floorArea     = longSide × shortSide
footprint(d)  = π × r(d)²           r = bounding-sphere radius of die d at scale 1
required      = Σ footprint(d) over all dice in the roll
                (including the dice a first explosion could add)

scale = min(1, sqrt(0.30 × floorArea / required))

if scale < 0.40   → roll refused
if diceCount > 100 → roll refused (engine hard cap, independent of scale)
else               → all dice are spawned at `scale`

r(d) does not come from the shape at all. A die’s size_mm is how wide it is — the diameter of the sphere its corners sit on — so its bounding radius is size_mm / 2 whatever solid is inside it. A 16 mm d6, a 16 mm d12 and a 16 mm d20 are all 16 mm across at their widest, which is how a set of dice looks in a hand.

size_mm used to be read as a dice maker’s nominal size — the edge length for a polyhedron — and each shape had its own ratio from that to its bounding radius. That convention is defensible and is what a manufacturer quotes, but it is not what anybody means by “a 16 mm die”: a dodecahedron with 16 mm edges is 45 mm across. Dice that size under ordinary gravity take half again as long to fall their own length, and the roll reads as weightless — a miniature filmed at normal speed. See docs/dice-sets.md, “Size”.

That is: dice may collectively cover at most 30 % of the floor with their bounding circles, and they may shrink to 40 % of their size to get there. Both numbers are tunable constants and both are covered by the golden determinism tests.

Worked example on a Pixel 10a table (240 × 108 mm ≈ 259 cm²):

Roll Required at scale 1 Result
1d20 (16 mm) 2.0 cm² scale 1.0
8d6 (16 mm) 16 cm² scale 1.0
38d6 76 cm² scale 1.0 — the most that roll full size
60d6 121 cm² scale 0.80
100d6 201 cm² scale 0.62 — the engine’s whole cap
101d6 — refused: “101 dice don’t fit on the table; up to 100 do”
500d6 — refused

Note what the first column no longer does: the floor rule stops refusing anything. The two constants do different jobs — 30 % shrinks and 40 % refuses — and 16 mm dice are small enough that a hundred of them shrink only to 0.62, nowhere near the floor. It would take 241 before the floor refused one, and the engine stops at 100. The rule still does its real job, which is to shrink a crowded tray so the dice have room to tumble; the refusal a player actually meets is the body cap.

Both numbers were revisited on the Pixel 10a and left alone. At the cap the shrink they apply produces a roll where everything settles but one throw in sixty, nothing is stacked at rest, nothing leaves the tray, and a step costs 3.09 ms against the 8.33 ms it has. There is no evidence for moving either, and moving a tuned constant on no evidence is how it stops meaning anything. The 241 is asserted in TableCapacityTest, so raising the body cap past it is noticed — that is the point at which the scale floor would become live for the first time.

The refusal message always says the largest count that would fit, and offers to open the outcome graph instead, which has no such limit.

An explosion that would take a roll past what the table holds stops; it is not refused. The check above counts the dice a first explosion could add, because those are known before anything is thrown. A chain can go deeper than that, and nobody knows how deep until the dice land — by which time the roll is on the table and has been read, so there is nothing left to refuse. The limit is therefore asked again before each added die, as the physical question it really is: is there a patch of clear floor to drop one onto, and is the tray still under the hundred bodies the engine takes. When the answer is no the chain ends there and the breakdown says so. The one thing that never happens is a die dropped onto the pile to keep a chain going (docs/physics-and-rendering.md, “The dice an explosion or a reroll adds”).

Why refuse rather than batch or grow the table: a physics engine with hundreds of convex bodies packed into a small box tunnels, jitters and explodes. The result would not be a roll of the dice, it would be a bug. The outcome graph already answers “what does 500d6 look like”; the table answers “what did these dice do”, and that only means something when they had room to do it.

Table looks

A table look is a [[table]] entry in a package’s diceset.toml (docs/dice-sets.md). A package can contain only tables — a “table pack”.

[[table]]
id = "green-felt"                 # slug, unique within the package
name = "Green felt"
floor_texture = "tables/felt.png" # optional, relative, inside the package; the colour picture
floor_normal = "tables/felt-n.png" # optional normal map, OpenGL convention (green up)
floor_roughness = "tables/felt-r.png" # optional roughness map, grey, read from red
floor_tile_mm = 80                # optional: one copy of the pictures covers 80 mm square
floor_tiling = [3, 6]             # without floor_tile_mm: repeats across short/long side, default [1, 1]
wall_texture = "tables/oak.png"   # optional; wall_normal, wall_roughness, wall_tile_mm likewise
wall_tiling = [8, 1]
color_mode = "average"            # multiply (default) | average: how the colour and roughness meet the pictures
floor_color = "#1f5e3a"           # the whole colour when no texture
wall_color = "#5a3a1e"
roughness = 0.9                   # 0..1
metallic = 0.0                    # 0..1
friction = 0.6                    # floor + walls, clamped to 0.2..1.0
restitution = 0.2                 # clamped to 0.0..0.6
sound = "felt"                    # felt | wood | glass | stone | plastic  (built-in impact sound sets)
light = "warm"                    # neutral | warm | cool | dim  (built-in lighting presets)

Rules:

Textures

A table is drawn from up to three pictures per surface — floor, and walls with the rim on top of them:

Key What it is Uploaded as
*_texture the colour picture, coloured by *_color as color_mode says sRGB, every mip level
*_normal a normal map, OpenGL convention: green points up the picture linear, every mip level
*_roughness roughness, grey (the red channel is read); with multiply it replaces roughness, with average it is moved to average out at it linear, every mip level

A picture is named by a path inside its own package, and only there. The validator stamps every look it reads with the id of the package it read it out of (TableLook.packageId) — that is not a key of the format, and a file that writes one is warned about and ignored — and the tray asks for each picture by the same key of package and path a die’s artwork uses (builtin::tables/felt-albedo.webp; docs/dice-sets.md, “How an atlas reaches the tray”). So a table cannot reach another package’s files, a look built in code with no package has no pictures to draw, and a downloaded table’s pictures go through exactly the path, size and header checks a die’s atlas does — at install and again when they are read.

The bundled package’s pictures are read from where it is. It is never installed to disk: it is a resource in the APK, validated on every launch by the same validator (BuiltinDiceSet). So InstalledArtwork answers the bundled id first, from the files the package was validated from, and every other id from its folder — the rule SetLibrary already keeps, so a folder on disk calling itself builtin is never reached. The bundled pictures change only with the APK, so one stamp lasts their whole life.

flowchart TD
  toml["[[table]] in a package's diceset.toml"] --> validator["DiceSetValidator: path, size<br/>and header of every picture"]
  validator --> look["TableLook, stamped with<br/>packageId = the package's id"]
  look --> keys["DiceMaterial.floorOf / wallOf:<br/>package::path for each map"]
  keys --> engine["FilamentEngine: one upload per key,<br/>mipmapped, kept with the engine"]
  engine --> seam["(String) -> AtlasImage?<br/>DieArtwork in :app"]
  seam --> which{"Bundled id?"}
  which -- "yes" --> res["The APK's resources,<br/>the files it was validated from"]
  which -- "no" --> disk["filesDir/dicesets/&lt;id&gt;/,<br/>if it still validates"]
  res --> decode["Size, then decode"]
  disk --> decode

How the colour meets the picture is the look’s to say (color_mode):

color_mode The surface is For
multiply (default) *_color × the picture, which is what the format has always said; a roughness map is the roughness a photograph — its colour is white and it is shown as it was taken
average the picture scaled, channel by channel, so it averages out at *_color on the screen, the lamps’ sheen included (“What the floors draw as”); a roughness map moved so it averages out at roughness a dyed cloth: #1f5e3a is the green the felt is, and the pictures add only its grain

The average is taken once, in linear light, from the pixels the picture is uploaded from (TableTint), and the scale stops at sixteen times so a nearly black picture cannot blow out. A roughness map’s average is taken as stored — it is a measurement, not a colour — and the whole map is moved by the difference, clamped to nought and one, so its grain keeps its depth and the surface is as glossy as the look says rather than as glossy as whatever was photographed. That matters more than it sounds: a photograph’s roughness is the photograph’s finish, and Poly Haven’s oak boards average 0.44, a lacquered floor. In the studio a surface that smooth catches the key light across the middle of the tray, and a white sheen over a dark brown is grey. With average the colour in the file is the colour of the surface, so the picker’s swatch — what every row shows in power-saving mode — still tells the truth about it. The bundled felt is grey, so the two felts are one cloth, and the bundled textured looks all use average with the colours they were always designed in.

Laid at real size. With *_tile_mm one copy of the pictures is that many millimetres square, measured from the floor’s corner: felt keeps its weave on a tall phone and a squat one, and a picture larger than the floor covers it once without a seam. Without it the floor is cut into whole repeats per side, which stretches the picture with the screen — what a photograph wants. On the walls the pictures walk round the tray from the top edge down, and the rim carries on from the top of the wall, so a wall drawn from oak has an oak rim.

Drawn with a material of its own. Felt and oak are drawn with a third material (DiceMaterial.TABLE_SOURCE): the dice’s materials are untouched, so every die is drawn exactly as before. It is compiled the first time a textured table is drawn and kept in MaterialCache like the others; a phone that only ever rolls on plain never compiles it. A picture that is named and does not come — a package removed, a file that will not decode — is drawn as the flat colour, and the tray is drawn anyway.

Built-in tables

The bundled package ships felt-green, felt-black, oak, dark-glass and plain (a neutral grey that is easy on the eyes and on the battery — it is what power-saving mode’s result screen echoes). dark-glass is the glossy one, at roughness 0.1: it is drawn like plain, in its colours alone, and shows the room sharply in its floor and none of the dice.

Table Floor Walls and rim
felt-green, felt-black needle felt, grey, 80 mm a copy, averaging out at floor_color and at roughness 0.9 oak, 300 mm a copy, averaging out at the frame colour (black felt’s is ebonised), as matte as the felt
oak oak boards, 300 mm a copy — the whole floor is inside one — oiled, averaging out at roughness 0.75 the same oak
dark-glass flat colour flat colour
plain flat colour — the look that costs least to draw, and it stays that flat colour

What the floors draw as. Every surface reflects the lamps on top of its colour. A dielectric gives back four per cent of the light straight on, and a rough one spreads that over a wide lobe instead of a highlight: under the studio’s key and room, looking straight down, a felt at roughness 0.9 gives back 1.4 % of white in every channel and oiled oak at 0.75 about 2.3 %. That is grey, and it is not small next to a dark colour: green felt’s red is #1f, 1.4 % in light, and the Pixel 10a drew #1f5e3a as (44, 94, 65) — the red doubled, the blue up seven — while the colour reached the material divided by the picture’s average and nothing else. The normal and roughness maps are not the cause: modelled with the felt’s own maps, the sheen is the same to a ten-thousandth as a flat surface’s at 0.9.

So a look in color_mode = "average" hands the material the colour that draws as its *_color once its sheen is added (SurfaceLight): the sheen is worked out from the key, the room, the exposure and the look’s roughness by Filament’s own GGX — its distribution, its visibility and the split-sum table it draws a room’s reflection with — for a surface facing up seen straight down, the Table view’s default, and taken out of the colour. A colour with a channel darker than the sheen cannot be drawn by any surface of that roughness under these lamps; it is lifted by the least grey that brings that channel up to the sheen, which keeps the differences between its channels — its hue — where clamping the channel alone would turn a dark brown maroon. Black felt is therefore drawn as its sheen, #202020 or so, as dark as a dielectric felt can be; it keeps its cloth, because most of a felt’s grain at screen size is the normal and roughness maps moving the sheen, not the colour picture. Oak’s blue is lifted the same way.

A floor drawn in its colour alone — plain, dark glass, any look that names no picture — has its sheen taken out the same way, whatever its color_mode, because a flat colour has nothing to multiply: its floor_color is what it draws as. Until decision 106 that happened by accident, under a mirror coat the dice material left on every surface it was not told to lacquer. Its walls are drawn in their colour as written.

TableTextureDeviceTest holds each textured floor, straight down, with the real room and the post pass, to its floor_color: green felt within ten levels a channel, oak and black felt within twenty — what is left there is the part no colour reaches, about six levels on black felt and twelve on oak’s blue — and oak to staying a warm brown, red over green over blue. Tilted, the key’s highlight falls nearer the middle of the floor and the sheen grows by a tenth or so; that shot is logged, not held. Oak was 0.55 once, and its photograph’s own map 0.44, and at either the highlight turned the boards pinkish grey.

A flat look — plain, dark glass, a look made in code — is drawn through the dice’s material, whose lacquer Filament leaves on at full strength when the look asks for none (material.clearCoat defaults to one, and the material only writes it when a die has a lacquer). Under that coat the surface’s own sheen is gone and what is left is a mirror’s four per cent of the ceiling straight overhead, which in the studio is dim: so the flat felt the studio was calibrated against (StudioLightDeviceTest) draws #1f5e3a as (34, 89, 58), eleven under the textured one’s red before this correction. Nothing above depends on that, and it is noted in docs/TODO.md.

Where the pictures come from, their licence (CC0) and how they were cut are in docs/assets/README.md, “Table textures”.

Your own photo

Use a photo sits at the foot of the table picker (design/dInfinity.dc.html, option 1u): it takes an image from the system picker, cuts it down to size, and writes a [[table]] entry and its picture into the personal package, mine (docs/dice-sets.md, “Packages the app writes”). It is then a normal table — listed with the rest, choosable, pinnable by a group or a saved roll, and exported inside mine’s zip like anything else drawn on this phone.

Nothing about it is a privileged path. The photo is written into the package and that whole package then goes through the same DiceSetValidator a downloaded one goes through. A package that comes back rejected is not installed, the photo is taken back out of the store it was put in, and the report the validator wrote is what the sheet shows — the same lines a refused install shows. So a refusal leaves the phone exactly as it was, which matters more here than anywhere: the photos are the record the package is rebuilt from, and one left behind after a refusal would take the drawn dice down with it at the next rebuild.

flowchart TD
  pick["The system picker<br/>(image/* only)"] --> bounds["Bounds-only decode:<br/>the header, no pixels"]
  bounds -- "not a picture" --> refused
  bounds --> plan["PhotoScaling: the target size,<br/>and the subsample to reach it"]
  plan --> decode["Subsampled decode,<br/>then one exact scale"]
  decode --> encode["WebP, quality 80"]
  encode -- "over 4 MiB" --> plan
  encode --> store["The photo store:<br/>tables/&lt;id&gt;.webp and its name"]
  store --> build["The whole of 'My dice',<br/>rebuilt from drafts and photos"]
  build --> check["DiceSetValidator"]
  check -- "valid" --> installed["Installed, and one more row<br/>in the table picker"]
  check -- "rejected" --> undo["The photo is taken back out"]
  undo --> refused["Refused, with every line<br/>the validator wrote"]

How small, and why that small. Neither number is this feature’s own. A phone camera makes something like 4080 × 3072 — forty-eight megabytes decoded — and a hostile file may claim very much more, so the size is decided from the limits the format already publishes:

   
longest side MAX_TEXTURE_PIXELS (2048), the aspect ratio kept, never upscaled — so a 4080 × 3072 photo becomes 2048 × 1542
file size MAX_TEXTURE_MIB (4). A 2048-pixel photo is not guaranteed to encode under it, so the size is a ladder: each rung halves the one before, down to 256 px on the long side, and the first rung whose encoded bytes fit is the one written. In practice the first rung always wins — a 2048 × 1542 WebP of a photograph is a few hundred kilobytes
format WebP, lossy, quality 80. A photograph in PNG is several times the size for a difference nobody can see under tumbling dice, and WebP is one of the two kinds the validator’s header reader already understands
how many MAX_PACKAGE_TEXTURE_MIB / MAX_TEXTURE_MIB = 6: as many textures as a package could hold if every one of them were the largest a texture may be. The seventh is refused rather than pushing the oldest out — a drawing nobody has opened for months is a fair thing to drop, and the table somebody is playing on tonight is not

The decode is in two passes, and that order is the whole of the defence. The first asks only for the header (inJustDecodeBounds) and allocates nothing, so a file claiming to be thirty thousand pixels square is found out for the cost of a few bytes. The second carries an inSampleSize — the largest power of two that still leaves enough pixels — so the decoder never holds more than about four times what is wanted, and one ordinary scale finishes the job. A full-size decode of an attacker-controlled image does not happen on any path.

What a photo table is, as a look. Its tiling is [1, 1]: tiling exists so a 512-pixel felt swatch can cover a tray without being a screen-sized image, and a photograph is one picture of one thing. Its floor colour is white, so the picture is shown as it was taken rather than multiplied by a tint; its walls keep plain’s grey. Its sound is felt and its light is neutral — a photograph says nothing about how hard a surface is — and every physics value is the model’s own default: a photo changes how the tray looks and nothing about how it rolls.

Its id is photo- plus the name slugged, numbered (photo-oak-2) when that id is taken, so a photo can never land on a drawn set’s table id. The name is the player’s, tidied and cut to 40 characters — the length an id may be — and suggested from the file’s own name (oak_table-02.jpg → “Oak table 02”), which is a suggestion to type over rather than an answer.

A photo table is also the one row in the picker that offers Remove, because it is the one look that does not belong to a package: everything else is removed by removing its package, on the screen that is about packages.

The tray draws the picture. A photo table’s look is read out of mine by the validator like every other, so it knows its package and its picture is found by the same key of package and path as any table’s (“Textures”, above). It is laid with [1, 1] tiling: stretched over the whole floor, whatever the screen’s shape. Whether a photograph should be cropped to the floor’s shape instead is still open (docs/TODO.md, “Open questions”).

Selecting a table

Thumbnails

Every row of the picker is a picture of its own tray: the real box mesh built from that look, lit the way the roll screen lights it, with a d20 standing on the floor (design/dInfinity.dc.html, option 1u). It is drawn by the renderer that draws the tray — FilamentDiceRenderer over Stage, the same scene a throw builds — because a second way of building it would be a second thing to keep in step, and the first time the two drifted the picker would be advertising a table the tray does not draw.

It is the first thing in the app to want the renderer on a screen that is not the tray, and that is the whole of what is new here (docs/architecture.md, decision 60).

A thumbnail does not follow the Table view setting. It is a picture of a table rather than a roll in progress, and the leaning shot is the one that shows a look’s walls — a straight-down thumbnail of an oak tray is a rectangle of felt. So the renderer a thumbnail is drawn with is built without a table view and takes this module’s own default, which is the angled shot (docs/physics-and-rendering.md, “Rendering (normal mode)”).

flowchart TD
  row["A row comes on screen<br/>(TablesScreen)"] --> wants["TablesPresenter.wants(pin),<br/>asked once per look"]
  wants --> seam["TableThumbnails — an interface;<br/>:app joins the two ends"]
  seam --> post["Posted to the roll thread,<br/>where the engine is (decision 49)"]
  post --> cache{"Drawn before?"}
  cache -- "yes" --> give
  cache -- "no" --> scene["ThumbnailPlan: the tray, the camera,<br/>the die and how it stands"]
  scene --> draw["A readable swap chain, one frame,<br/>read back, given back"]
  draw --> check{"All one colour?"}
  check -- "yes" --> none["Nothing. The swatch stays"]
  check -- "no" --> give["A Snapshot, kept<br/>(ThumbnailCache)"]
  give --> main["Onto the main thread,<br/>as a bitmap"]

What the picture is of. The tray is built for the thumbnail’s shape rather than for the phone’s — a table is its screen, and a thumbnail is a very small screen, so the same rule gives it a table of its own proportions. The camera is the tray’s own, at the far corner and as close as a pinch may ever take it (TrayView.CLOSEST): framing the whole 240 mm would put a 16 mm die across a twentieth of a picture 44 dp wide, which is true and is a picture of nothing. Close in, the die is a quarter of the frame, two walls and the rounded corner between them are in shot, and a floor texture is at a size somebody can see repeat. “The far corner” is the frame sitting flush inside it (TrayView.inTheCorner), which is nearer the middle than the furthest a player may pan: a player may stand the middle of the screen on the corner, which is what lets them read a die lying against a wall, and half of that frame is rim (docs/physics-and-rendering.md, “Rendering”). A thumbnail is a picture of a table, so it stops at the wall. The die shows its best face — the highest value, read from the die’s own faces rather than from its face count, so a d20 numbered 0–19 shows its 19 — and it is the bundled package’s d20, or another package’s when the bundled one is not there. A package with no twenty-sided die at all gets the tray by itself, which is still a true picture of the look.

Nothing about a thumbnail is a roll. No physics world is opened, no step is taken, and the die is placed where the arithmetic says rather than where a solver left it. A thumbnail decides nothing, because nobody reads a face off one (docs/architecture.md, goal 1).

It is the right way up. Filament hands a frame back top row first and the picture is kept that way. It used to be turned over once more, on the assumption that it arrived the way OpenGL counts rows, so every thumbnail was upside down and its 19 mirrored. Which way up a frame comes back is pinned on the device by geometry alone (docs/build-setup.md, “What the renderer’s device test can and cannot say”).

The swatch is still there, and it is the fallback. Two colours in a box — the floor inside, the wall around it — drawn at exactly the size the picture will be, so the list does not jump about as pictures land in it. It is what every row shows for the first moment of a visit, and what every row shows for ever on:

What it costs. A picture is asked for by the row that is on screen rather than for the whole list at once, so somebody with thirty installed looks pays for the six they can see and for the next six when they scroll to them. Each one is a swap chain, a scene and one wait on the GPU, and each is given back the moment the frame has been read. The answers are kept — a look’s colours cannot change without its package being reinstalled — bounded at sixteen, the least recently asked-for dropped first, and they outlive a visit to the screen because the engine does (docs/architecture.md, decision 50). A picture is filed under which table it is and what that table is, so a photograph removed and another made under the same id is drawn afresh rather than shown the first one’s picture.

A thumbnail shows a table’s pictures, like the tray does. It is drawn by the same renderer on the same engine, so the felt, the oak and a photograph reach the picker with nothing done for them here. Where a thumbnail cannot be drawn the swatch is floor_color and wall_color, which for a look in color_mode = "average" are the colours its surfaces average out at.