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.
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.
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.
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:
floor_tiling or floor_tile_mm lets a
small felt swatch cover the floor without a screen-sized image.floor_tile_mm and wall_tile_mm are clamped to 10–1000 mm. When one is
given it replaces the matching *_tiling. color_mode is multiply or
average; anything else is refused, like an unknown sound.roughness: a smooth floor reflects the room sharply and nothing else. Seen
from straight above, or the angled view’s 22° off it, a die’s reflection
would lie under the die and be hidden by it
(docs/physics-and-rendering.md, “A glossy table”; docs/architecture.md,
decision 104).docs/physics-and-rendering.md, “Dice bodies”), so on
the built-in tables — felt at 0.2, oak at 0.35, glass at 0.45 — the dice
set’s number is the one in force. Friction is shared: the geometric mean of
the two.AudioTrack
as raw PCM. So no decoder takes part at all, on a stranger’s file or on the
app’s own, which is this rule carried one step further than it had to be. Felt
is almost all noise and gone in a fiftieth of a second; glass is almost all
ring and hangs on about four times as long
(docs/physics-and-rendering.md, “Impacts, haptics and sound”).sound names what the table sounds like, not what the roll sounds
like. Dice hitting each other sound like dice whatever they are landing on,
so those impacts take the plastic preset — which is what a set of acrylic
dice is — and the table’s preset covers the floor and the walls. A player who
wants none of it turns sound off in Settings.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/<id>/,<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.
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”.
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/<id>.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”).
Saved-roll groups can pin a table (“the Strahd campaign is always played on black felt”), and so can an individual saved roll (“Fireball is thrown on black felt”). Precedence, most specific first: the saved roll’s pin, then the pin of the group it lives in, then the app default from Settings. The roll’s pin is set in the roll editor and the group’s on the group sheet; “Default” in either means follow the pin above this one.
The rule is answered where both halves are known. A saved roll and the
group it lives in arrive together — the saved-rolls list and the strip both
watch the two flows as one — so the precedence is settled at the moment a
roll is tapped and the throw carries the answer with it (SavedRollSource).
Nothing downstream asks a database which table to use, and the roll screen
never learns what a saved roll is. null means the app default, and the
tray cannot tell whether that is because nothing was pinned or because the
default is what was pinned — which is the same thing to a player.
The tray is told again whenever the table changes, and only then: tapping a roll pinned to black felt changes it, typing over that formula changes it back — the pin travels with the attribution, because a roll that was Fireball and has been edited is not Fireball’s throw — and a keystroke that changes neither leaves the scene alone.
A throw started from the saved-rolls list carries no pin, because it carries no attribution either: that tap fills the formula field and the player makes the throw themselves. It lands on the app default, like anything else typed.
sound is still used too — nothing is drawn
there, but the dice are still heard, from the impacts the throw actually made
(docs/physics-and-rendering.md, “Power-saving mode”).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:
docs/physics-and-rendering.md, “Power-saving mode”).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.