From Panel to Cylinder
Wrapping the heightfield around a cylinder: what survives the wrap, what does not, and
the two objects it gives you
A carved column read in reflected light, and a thin lamp shell read in transmitted
light. The column is built and unprinted. The shell has been printed twice — and the second
print overturned this page's ranking of its own two variants, which is §7.
0. The move
§7.2 of the build guide states the assumption everything rests on: the surface is
z = f(x, y), one height per pixel. Branch B reads that as a
limitation to be escaped. There is a second reading, and it is the cheaper one.
The assumption is not really “height along +Z”. It is “one height per point of a base surface, measured along that surface’s normal”. The plane is a choice of base surface, not the only one.
Choose a cylinder instead and the height becomes a radius. Every property that made branch A
tractable follows from the graph structure, not from the plane, so every one of them survives:
the mesh is still watertight by construction, the surface still has no undercuts, and every
conditioning stage in heightfield.py — guided filter, range compression,
background suppression, slope limiting, forms, engraving, round_trunks — runs
unchanged on the same array.
base surface height measured along what the picture's axes become
------------ --------------------- ------------------------------
plane (branch A) +Z, everywhere x -> bed X, y -> bed Y
cylinder radially out from axis x -> theta, y -> bed Z
sphere radially out from centre no distortion-free choice exists
What does not survive is anything that was really a statement about the plane, and all of it follows from one fact: the picture’s horizontal axis stops being a spatial axis and becomes an angle, and the picture’s vertical axis stops being a bed axis and becomes the print’s build direction. Two consequences change the field itself — the seam (§2) and the overhang limit (§3). A third changes nothing in the field but inverts how you judge and slice it (§4).
§§1–5 build the first object: the v6 carved relief as a solid column, judged by the light that falls on it. §6 builds the second, which uses every word of §§2–4 unchanged and then inverts the design problem — a thin shell whose wall thickness carries the picture, judged by the light that gets through it. §7 is what happened when both shells were printed, and it corrects §6 on the point §6 was most confident about.
1. The geometry
One decision fixes everything else: the picture wraps a full turn, so the circumference carries the painting’s long axis. Keeping Bruegel’s aspect ratio then determines the diameter — it is not free.
| quantity | value | why that value |
|---|---|---|
| print height | 170 mm | the A1 mini’s 180 mm Z, with margin |
| circumference | 238.87 mm | forced by the aspect ratio 6819 × 4853 |
| base diameter | 76.03 mm | where the relief is zero |
| diameter at the peaks | 108.03 mm | base + 2 × CYL_RELIEF_MM |
| bore | 68.03 mm | base − 2 × CYL_WALL_MM; a plain cylinder, left clear |
| field grid | 955 × 680 | PITCH_MM 0.25 on both axes |
| arc pitch, base → peak | 0.250 → 0.355 mm | samples spread as the radius grows — still under the 0.42 mm bead |
That last row is the one worth pausing on. On a flat panel the sample pitch is constant. Here the angular step is constant, so the arc between samples grows with radius: the field is sampled 42 % more coarsely on the peaks than in the valleys. At 16 mm of relief the peak arc is 0.355 mm, still inside the bead, so nothing is lost. The crossing point is at about 26 mm of relief — past that the peaks are sampled more coarsely than the printer can resolve, which is a quiet way to waste detail.
Relief depth means something different here
On the panel, RELIEF_MM = 16 is 9.4 % of the 170 mm width — a
shallow carving. Wrapped, the same 16 mm is 42 % of the base radius. The
comparison that governs how the object reads is against R0, not against the picture’s
width, and 42 % is a lot: the column visibly swells and narrows as you turn it.
Why not scale the picture down instead — keeping the flat build’s 170 mm as the circumference
- circumference 170 mm → diameter 54.1 mm, height 121 mm
- Safe, quick, and half the material. It also puts 16 mm of relief on a 27 mm radius
— 59 % — which stops being a relief on a cylinder and becomes a lumpy solid. If
you want the small version, drop
CYL_RELIEF_MMto about 7 to keep the same ratio the 170 mm version has.
2. The seam
Column 0 and column W−1 of the field are now neighbours. Three separate things follow, and only the first is obvious.
2.1 The mesh must not duplicate a column
The angular step is 2*pi/W, not 2*pi/(W-1): W samples around a
closed loop leave W gaps, not W−1. Use the wrong one and the last column lands on top of the
first — a duplicated 0.25 mm stripe of picture and a ring of degenerate quads exactly
where the join should be.
2.2 Every filter must see across it
This is the one that bites. heightfield.py is a stack of neighbourhood
operations — a 13-px guided filter, Gaussians, a 112-px rolling-ball erosion for the
background envelope, a 35-px horizontal opening in round_trunks. Every one of them
uses SciPy’s mode="nearest" at the array edge, which invents a boundary by
repeating the last column. On a flat panel that boundary is real. On a wrapped panel it is a
fiction, and the fiction shows up as a vertical band of wrong conditioning down both sides of the
join.
The fix is upstream of everything and costs nothing: pad the field circularly before
conditioning, crop after. CYL_PAD_MM = 30 is 120 px, comfortably wider than the
largest neighbourhood in the stack.
def circular_pad(a, pad_px):
return np.concatenate([a[:, -pad_px:], a, a[:, :pad_px]], axis=1)
Note what this asserts: that the painting’s right edge really is the left edge’s neighbour. It is — on the finished object. Circular padding is not a convenience here, it is the physically correct boundary condition.
2.3 The rim has only two sides now
hf.apply_rim fades all four edges down to the plate. Left and right are no longer
edges; fading them would cut a groove down the join and, worse, would make the two halves of every
object that straddles it disagree about its height. cyl.apply_band_rim is the same
smoothstep on the vertical axis only.
2.4 What is left over
None of that makes the join invisible, because the painting genuinely has a left edge and a right edge and they are different pictures. Measured on the finished field, the residual step is 5.43 mm at worst, 1.75 mm on average — a cliff running the full height of the column, where the mountains on the right of the picture meet the trees on the left.
It prints without complaint: a radius discontinuity lies within a layer, so it is a sharp corner in the toolpath and nothing more. Whether it reads as a flaw or as the join of a wrapped panel is a judgement to make in the hand. If it reads as a flaw, the honest fix is not to blend the picture into itself — that manufactures a ghost — but to widen the step into a deliberate vertical stile, a few millimetres of flat, and let it be a piece of joinery.
2.5 Giving the seam somewhere to live
None of the above addresses the seam the slicer makes. Every closed extrusion loop has to start and stop somewhere, and a cylinder is the worst case in printing: there is no corner to hide it in, so it lands in the middle of the picture and reads as a scar. On the lamp shell it is worse than cosmetic, because a start-stop is a local thickness anomaly and thickness is the image.
Slicers choose the spot by preference, and the preference is documented: Bambu’s seam logic ranks candidates “concave non-overhang vertex > convex non-overhang vertex > …”. So the fix is not to fight the algorithm but to feed it.
Measure the sharpest concave corner the picture contains, then cut one sharper. The slicer will park the seam in it, on every layer, unprompted.
On the shell’s field the sharpest concave vertex anywhere is 88.7°, and essentially nothing falls below 90°. A V-groove 1.2 mm deep and 1.2 mm wide gives an apex of 60.9° — a 28° margin, on every layer, at a fixed angle. And it is put at the picture’s own join, so the one vertical line the object has coincides with the one discontinuity it already had.
seam_channel subtracts the same profile from the outer radius and the inner
radius. The surface gains a corner; the wall keeps its thickness to 1 part in
1012. So on the column it is a visible stile, and on the lamp shell the
transmitted image cannot see it at all — a groove would otherwise print as a bright line,
which is precisely what you are trying to avoid.
_two_surface_shell tests for z-invariance rather than for a constant radius
— testing the latter doubles the file for a 1.2 mm groove.3. The overhang — the wrap’s own constraint
On a flat panel nothing overhangs. Every relief surface faces up; the no-supports guarantee of §7.2 is free. Standing the picture on end spends that guarantee, and it is worth being precise about how, because the two picture axes behave completely differently.
A layer is a closed curve at constant Z. Moving along the picture’s horizontal axis moves
within that curve. A vertical cliff in the picture is a corner in the toolpath. There is no
angle at which circumferential steepness needs support, so MAX_SLOPE_DEG in
config.py is doing nothing for printability in this direction — it is a
bead-and-finish setting, which is all it ever was.
Moving up the picture moves up the layer stack. If the radius grows as you rise, the wall leans
outward over air. The lean angle from vertical is atan(dr/dz), and past about 45°
it needs support.
MAX_SLOPE_DEG = 62
is tan 62° = 1.88 mm of rise per mm. Rescaled by the 170/121 stretch the wrap
applies to the vertical axis, that is still a 53° lean — eight degrees the wrong side of
the rule.Measured on the real field: 8.4 % of the mesh’s surface area prints into air without a limiter. That is not a supports-and-live-with-it number on a surface whose whole point is its finish.
The fix is one sweep. Walk up the rows from the bottom, and never let a row sit further out than the row below it plus one row’s worth of allowed lean:
step = np.tan(np.radians(max_deg)) * pitch_mm / relief_mm
for i in range(h.shape[0] - 2, -1, -1): # row 0 is the TOP
np.minimum(h[i], h[i + 1] + step, out=h[i])
Three properties make this the right shape of fix rather than a hack. It only ever removes material, so it can never introduce a new problem elsewhere. It leaves every inward lean untouched, because a shrinking radius is self-supporting at any angle. And it is exact: after the sweep no row can violate the limit, so there is nothing to iterate and nothing to check afterwards except that the census agrees.
4. The two resolutions swap
An FDM printer has two very different resolutions: the layer height, fine (0.08–0.2 mm),
and the bead width, coarse (0.42 mm for a 0.4 nozzle). The build guide’s whole carving
strategy — engrave rather than emboss, cap the raised detail, judge
07_print_sim.png and never the raw hillshade — is built on which axis gets
which. Wrapping swaps them.
| what is being resolved | flat panel | standing cylinder |
|---|---|---|
| the relief itself (depth) | layer height, 0.08 mm | toolpath radius — not quantised at all |
| picture’s horizontal axis | bead width, 0.42 mm | bead width, 0.42 mm |
| picture’s vertical axis | bead width, 0.42 mm | layer height, 0.2 mm |
The depth row is the surprising one. On the panel, height is built by stacking layers, so it comes in 0.08 mm steps. On the column, radius is set by where the slicer puts the perimeter within its layer — the bead moves in or out as a whole, and its outer face goes with it. There is no staircase in the relief direction, because the relief direction is no longer the build direction.
The vertical row is the one with a practical consequence. Detail up the picture is now limited by the layer height rather than the bead, which is better — but only down to the field’s own 0.25 mm sample pitch. At a 0.2 mm layer the printer is already sampling the picture more finely than the field describes it.
So print this at 0.2 mm, not at 0.08 mm. On the panel the fine layer bought depth precision; here it buys nothing the field can supply, and costs 2.5× the layers.
Which means the print simulation has to change too
hf.nozzle_preview encodes the flat panel’s arrangement: an isotropic disc
morphology in the picture plane, then round(h / layer). Both halves are wrong here
— the morphology should be anisotropic, and the height quantisation should not exist.
cyl.nozzle_preview_cyl does bead morphology across theta, layer quantisation down z,
and nothing at all across r. The two simulations differ by 0.072 mm RMS over the field, which
sounds small until you notice it is concentrated exactly on the fine drawing the guide spent
§13 learning how to keep.
5. Printing the column
| flat v6 panel | this column | |
|---|---|---|
| bounding box | 170 × 121 × 20 mm | 103.1 × 100.7 × 170 mm |
| enclosed volume | 216.7 cm³ | 420.0 cm³ — 1.94× |
| footprint on the plate | 170 × 121 mm | a 4 mm ring, 905 mm² |
| layers | 250 at 0.08 mm | 850 at 0.2 mm |
| supports | none | none, given §3 |
| mesh | 331,445 verts, watertight | 730,575 verts, watertight, Euler 0 |
Euler characteristic 0 rather than 2 is not a defect: a tube open at both ends is a closed surface of genus 1. It is watertight, winding-consistent and positive-volume, which is what a slicer needs.
Where those two volumes come from — read the ratio, not the absolutes
- both figures: the same pipeline, the same stale depth cache, one sandbox session
- The 216.7 cm³ is
main.pyat v6 settings re-run alongsidemain_cyl.py, so the two share every input and the 1.94× ratio is apples-to-apples. Neither absolute is what a real run on a freshly inferred depth map will report — the bounding boxes and vertex counts are fixed by the grid and are exact, the volumes are not.
- Brim, not optional. 170 mm of height on a 905 mm² ring, with material swelling to 108 mm across further up. The mass stays on the axis so it will not tip, but there is nothing else holding that ring down. Brim 4 mm, gap 0, and glue.
- The bore stays empty. The inner wall is a plain 68 mm cylinder; only the outside carries relief. Sparse infill fills the wedge under the peaks, so the 420 cm³ figure is enclosed volume, not filament — slice it for the real number before committing an evening to it.
- One outer wall, slow. The entire deliverable is the outer perimeter of every layer. The build guide’s §10 outer-wall speed and acceleration advice matters more here than anywhere in branch A, because on a column there is no top surface to hide behind.
- Seam placement. The slicer’s Z-seam is a second seam, unrelated to §2’s. Park it on the picture’s own join so there is one visible vertical line instead of two.
6. The lamp shell
The second build off the same wrap, and the opposite reading of it. The column is a solid object that you look at: light falls on it, its surface catches the light, and the relief is the whole point. The shell is a thin wall that you look through: the light is inside, and what reaches you is whatever survived the wall.
On the shell the picture is not the surface. The picture is the thickness.
6.1 Why v1, and why not v6
The column runs the v6 recipe. The shell runs v1 — the canonical four stages of §§1–11 of the build guide, unchanged: snap the depth onto the painted edges, flatten the histogram, compress the range in the gradient domain, add the brushwork back as fine texture. Everything v2 through v6 added is left out, and not for cost.
Every stage in that later machinery is a statement about how a surface catches light.
Background suppression exists so objects stand proud of the ground they sit on. Form raising and
round_trunks exist so a trunk reads as a cylinder rather than a levee at a grazing
angle. The slope limiter rounds columns so the bead can articulate them. A wall read by
transmission has no ground to stand on, no grazing angle, and no highlight — there is only
how thick it is, everywhere.
One of those stages is not merely unnecessary but actively inverted:
§13's central discovery is engrave, don't emboss: raised fine lines melt on the nozzle, recessed ones print crisply, so the painting's dark drawing is cut in. On a reflective surface a groove reads dark, which is correct. On a transmitting wall a groove is thinner wall, which reads bright. Run v6 on a shell and every dark line Bruegel drew comes out as a light one.
v1 also compresses harder than v6 — COMPRESS_ALPHA 4.0 against 2.0 —
which is exactly what a shell wants: a gentle field with no cliffs, spending its whole range on
the picture rather than on separating figure from ground.
6.2 The geometry
| quantity | the column | the lamp shell |
|---|---|---|
| recipe | v6 carved | v1 canonical |
| height | 170 mm | 145 mm |
| circumference | 238.87 mm | 203.74 mm |
| base diameter | 76.03 mm | 64.85 mm |
| wall | 4 mm, plus up to 16 mm of relief | 0.8 → 3.2 mm, and that is all of it |
| bore | 68.03 mm | 63.25 mm (outward) / 58.45 mm min (inward) |
| relief as a fraction of R0 | 42 % | 7.4 % |
| arc pitch, base → peak | 0.250 → 0.355 mm | 0.250 → 0.269 mm |
| field grid | 955 × 680 | 815 × 580 |
The 0.8–3.2 mm window is not chosen for structure. It is the range over which white PLA actually does anything optically: below about 1 mm it is properly translucent, above about 2 mm it is nearly opaque, and lithophane practice puts the working range at roughly 0.6–0.8 mm for the highlights and 2.8–3.2 mm for the darks. 0.8 mm is also exactly two perimeters at a 0.4 nozzle, which matters in §6.7.
6.3 Two shells, one picture
Where the picture hangs on the shell is a free choice, and both are exported from one run:
outward r_out = R0 + RELIEF*h bore a plain cylinder at R0 - FLOOR
inward r_out = R0 (a plain cylinder) bore at R0 - t
Both have thickness FLOOR + RELIEF×h. The two shells transmit exactly the same picture — not approximately, identically.
So the choice is not about the image. It is about three other things.
- Unlit. The outward shell is an object when the lamp is off. The inward one is a plain cylinder.
- Refraction. Surface slope steers light. The outward shell puts its slopes on the exit face, where the ray goes from n ≈ 1.5 to 1.0 — the larger deflection of the two interfaces, and the only one where total internal reflection is available at steep facets. If anything in this object is going to throw a caustic on a wall, that is where it comes from. The honest caveat: an FDM surface is bead-textured and scatters diffusely, and diffuse scatter is a much larger effect than clean refraction, so this is a second-order term riding on an identical first-order one.
- Overhang. Nothing on the inward shell's visible face can overhang, because it is a cylinder.
The overhang limiter is the same call for both — which is not a coincidence
- outward: the radius must not grow too fast going up. inward: the wall must not thicken too fast going up.
- Outward, the surface is at
R0 + RELIEF*h, so leaning out means h growing. Inward, the bore is atR0 - FLOOR - RELIEF*h, so leaning in over the bore also means h growing. Both reduce todh/dz ≤ tan(45°)/RELIEF, which is whatlimit_overhangenforces — one function, both shells, no mode argument.
And on this build it costs essentially nothing. The steepest upward lean anywhere in the v1 field is 70.7°, but only 0.022 % of cells exceed 45° at all: 9.9 mm² of airborne surface, 0.017 % of the mesh, removed for 0.005 % of the height budget. Compare the column's 8.4 %. The overhang problem is a deep-relief problem — 2.4 mm of relief over 145 mm simply cannot lean far.
6.4 The exponential, which is the real problem
Light through the wall follows Beer–Lambert:
At the placeholder μ = 1.25 /mm, a 0.8 mm wall passes 0.368 of the light and a 3.2 mm wall passes 0.018 — a 20:1 range, which is a good tonal range. The trouble is where it lives:
Half of that entire brightness range is spent between 0.8 and 1.32 mm — the first 22 % of the wall budget. Everything past about 2.4 mm is the same near-black.
Send the heightfield straight to thickness and most of the picture lands in the crushed end. Measured on this field: median normalised brightness 0.35, with 15.6 % of it below 10 % brightness. The fix is the one lithophanes have always used, written out as a stage — choose the brightness you want, then solve for the wall that delivers it:
b_hi = np.exp(-mu * floor_mm) # the thin end's brightness
b_lo = np.exp(-mu * (floor_mm + relief_mm)) # the thick end's
b = b_hi + (b_lo - b_hi) * h # LIGHT linear in the field
t = -np.log(b) / mu # the wall that delivers it
The ends are unchanged by construction, so the remap stays inside the same wall budget, and it is monotone, so the foot, the overhang limiter and the mesher are all untouched. Median brightness goes 0.35 → 0.68 and the crushed fraction 15.6 % → 1.0 %. It also uses less plastic — 35.6 cm³ against 50.0 — because the midtones no longer sit at 2 mm.
CYL_LAMP_TONE = "linear-thickness": the
heightfield goes straight to the wall and the lower half of the picture is gone. Right,
"linear-light": thickness solved from the brightness. The hunters, the dogs and the
village survive on the right and do not on the left.Thickness comes from depth: near is thick, thick is dark. In Hunters in the
Snow the near things — the trees, the hunters, the dogs — are also the dark things,
and the far sky is the pale one, so the transmitted image lands close to the painting's own
tonality. That is the subject's doing, not the pipeline's. Feed it a painting with a bright
foreground against a dark ground and the lamp will show you a negative;
INVERT is the switch, and the deeper answer — driving thickness from luminance
rather than from depth, which is what a true lithophane does — is a different pipeline, not a
different parameter.
6.5 μ is not a constant of nature
1.25 /mm is a placeholder chosen to span the 0.8–3.2 mm window that practice uses. The real value depends on the filament, its pigment loading, and — because a printed wall is not a solid one — on how solidly it prints. Published work on translucent additive manufacturing applies the Beer–Lambert form and confirms transmittance falls monotonically with thickness, but does not hand you a coefficient for white PLA on an FDM machine. So measure it:
Then set CYL_LAMP_MU_PER_MM and re-run. Nothing else in the build changes: μ
enters only through tone_for_transmission and the preview.
Why the wedge is built by mesh.py — and not out of boxes
- a staircase is a heightfield
- Thirteen butted boxes share coincident faces and the union is not watertight. But a stepped bar
is one height per (x, y), which is branch A's own assumption, so
mesh.heightfield_to_meshbuilds it — watertight by the same construction as every panel, and with no new dependency. Euler characteristic 2, as a solid block should be.
6.6 The ends: no rim, a foot and a collar
apply_band_rim fades the field to zero at the top and bottom. On the column that
gives a clean recessed border. On the shell it would put the thinnest wall exactly at the
two rims — where the object is handled, and where it meets the plate. A 0.8 mm ring at
this diameter is 161 mm² of bed contact holding up 145 mm of tube.
So the shell gets the opposite treatment. add_foot raises the bottom 6 mm to
at least 2.4 mm of wall — 471 mm² of first layer, and a plinth — and the
top 3 mm to 1.6 mm so the rim is not a knife edge. Both are raises, never cuts. The foot
thickens going down, so it is self-supporting by definition; the collar thickens going up,
but its steepest growth is 21.6°, well inside the 45° limit.
6.7 Printing it
| the column | the lamp shell (outward / inward) | |
|---|---|---|
| bounding box | 103.1 × 100.7 × 170 mm | 68.3 × 68.1 × 145 / 64.85 × 64.85 × 145 mm |
| enclosed volume | 420.0 cm³ | 35.6 / 35.1 cm³ — about a twelfth |
| mesh | 730,575 verts | 531,380 / 945,400 verts, both watertight, Euler 0 |
| footprint | a 4 mm ring, 905 mm² | a 2.4 mm ring, 471 mm² |
| airborne overhang | 8.4 % before the limiter | 0.017 % before the limiter |
| layer height | 0.2 mm (§4) | 0.12 mm — see below |
| layers | 850 | 1,208 |
- 0.12 mm, against §4's own advice. §4 argues that a 0.2 mm layer already samples the picture more finely than the field's 0.25 mm pitch, so nothing geometric is gained below it. That argument is about geometry and it still holds. It says nothing about optics: on a wall you look through, every layer boundary is a scattering interface, and coarser layers band the transmitted image whether or not they resolve it. Lithophane practice is unanimous that layer height is the single most important setting. This is the one place where the convention beats my own argument, and the wedge — printed at both heights — settles it in an hour.
- 100 % infill, wall loops to the maximum. A wall that runs 0.8 to 3.2 mm is 2 to 8 beads thick. Any void the slicer leaves inside it is a scattering cavity, and it will show as a blotch that is in no painting. Set walls to 99 and infill to 100 and let the slicer make the whole wall solid.
- White PLA. It transmits evenly across the spectrum; coloured filament tints the image and translucent filament costs you control of the dark end.
- Brim, still. 471 mm² is three times the bare rim but it is still a narrow ring under a tall part.
- No supports, either shell. §6.3.
- Vase mode is not available — the wall thickness is the picture, and vase mode prints one bead of constant width.
6.8 What the lamp still needs
This section builds the shell and stops there, which was the point. What is left is not geometry:
- A source and a diffuser. The bore is 63 mm clear, open at both ends. A bare point source inside a 32 mm radius will hot-spot; the transmission previews on this page all assume even illumination from within, which is a lie that a diffuser has to make true.
- Rotation. Mechanical, and outside this pipeline.
- The projection. The genuinely open question, and it does not follow from anything here. A shell has no imaging optic, so what lands on the wall is not the picture but a badly defocused version of the transmission map, brightest where the wall is thinnest, its sharpness set by how small the source is. The outward shell's surface slopes steer some light on top of that. Whether the result is a shadow of Hunters in the Snow or an interesting smear is a question for a dark room and a bulb, not for this page.
7. In the hand — L1, and the answer to it
Everything above this line is a prediction. Two shells have now been printed, and this section is the first thing on this page that was measured off an object rather than off an array. It is written in the order it happened: a shell that failed, a diagnosis with two separate parts, a second shell that fixed both, and then a result none of the modelling on this page could have produced — because the model is right, and the ranking built on top of it was wrong.
Three things above this line have since been overtaken, and are corrected here rather than rewritten in place, because what was believed at the time is the useful part of the record.
- §2.5's seam channel is 1.2 mm deep by 1.2 mm wide, sized against a depth-led field whose sharpest concave corner is 88.7°. That channel tilts the wall band far enough to take it under one bead, and on L2's first slice the wall was severed. The shipped channel is 0.8 deep by 3.2 wide. Against a luma field the corner analysis also comes out completely differently — §7.8.
- §§6.1–6.3 and 6.7 describe L1, the shell that failed: depth-led, wrapped once, Ø64.85. L2 is luma-led and mirrored, and its geometry is in §7.2. The optics of §§6.4–6.6 are about the wall itself and are unchanged.
- §6.3's ranking of the two shells is wrong, and §7.5 is why. Its statement that they transmit the same picture is right, and is the interesting part.
7.1 L1: what a depth-driven wall looks like in plastic
L1 is the shell of §6.2 exactly as specified: v1 recipe, depth-led, 145 mm tall, wrapped once, relief facing out.
The picture is faintly there if you know where to look, and it is not there otherwise. This is not a print defect; the mesh built and sliced clean. It is the field. Local contrast on L1's transmitted range measures 0.0197 rms overall and 0.0146 across the hunters — under two percent of the range, sitting behind white PLA's own 57–63 % haze. There was never anything to see.
The rung at the end of §6.4 says it: thickness comes from depth, and the fix is “driving thickness from luminance rather than from depth, which is what a true lithophane does”. It then files that under a different pipeline, not a different parameter and moves on. That was the error — not the physics, the triage. The hunters read in the painting because they are dark figures on snow. They stand on ground at their own distance, so in depth they barely exist. A depth-driven wall cannot show you a figure whose only claim on the eye is its tone.
The torch photograph closes the other question in the same afternoon. §6.8 lists the projection as genuinely open; with a source small enough to cast anything, nothing survives the wall. That is consistent with the published haze figures for white PLA — 57.6 / 61.3 / 62.9 % at 0.8 / 1.6 / 2.4 mm — and it means the projection needs a different object (clear filament, or a mask cut through), not a better lamp.
7.2 Two complaints, two independent changes
Standing L1 on the table produces two separate objections, and it is worth keeping them apart because they have nothing to do with each other.
- Nothing reads. The wall is driven by the wrong quantity. → drive it from luminance.
- It does not read as a round thing. A picture wrapped once around a cylinder is a flat picture bent into a tube. It has a front, a back, a join you are meant not to notice, and at Ø64.9 you can only ever see a sliver of it. Walking around it is not rewarded. → wrap the picture with its own reflection.
The second change is the one that turns a rolled-up poster into an object made in the round: follow the picture with its mirror image, so that both joins are mirror lines rather than seams. A kaleidoscope, not a panorama.
It is nearly free. mirror_wrap runs the samples 0…W−1 and
then W−2…1, so the reflection shares its end columns with the original and
no column is duplicated. Both joins are then continuous exactly, to 0.0 —
there is no step to hide, because a mirror line is not a discontinuity. The circumference doubles,
and with it the diameter, which is the whole gain: twice as much picture is in front of you at any
angle, and the object acquires two vertical axes of symmetry that read as deliberate rather than as
a joint. The cost is that the painting appears twice, which on a cylinder nobody sees whole is a
smaller price than it sounds.
| quantity | L1 | L2 |
|---|---|---|
| driver | depth — v1 recipe on the depth map | luma — the painting's own tonality |
| wrap | once around | mirrored — picture + reflection |
| height | 145 mm | 145 mm |
| field grid at 0.25 mm | 815 × 580 | 1628 × 580 — 2 × 815 − 2, the shared end columns counted once |
| circumference | 203.75 mm | 407.00 mm |
| base diameter | 64.86 mm | 129.55 mm |
| bore | 63.26 mm outward | 127.95 mm outward / 123.15 mm min inward |
| wall | 0.8 → 3.2 mm, unchanged — the optical window of §6.2 does not care about any of this | |
| joins | one seam | two mirror lines |
| local contrast, hunters | 0.0146 | 0.158–0.176 — eleven to twelve times |
Circumference is samples × pitch throughout: 815 × 0.25 and 1628 × 0.25. §6.2's table rounds L1's to 203.74 and its diameter to 64.85, one hundredth low in each; the values here are the arithmetic.
The luma recipe also deletes most of the pipeline. The guided filter existed to snap a depth map
onto the painting's edges, which is pointless when the field is the painting;
add_detail existed to put luminance back into a depth field, where it already is; and
compress_range is actively harmful here, 0.1172 → 0.0987, because it
attenuates large gradients — which on a depth map are the global recession and on a luminance
map are the figures themselves. The whole v1 chain was scaffolding for getting luminance into a
depth-driven field, and it evaporates when luminance is the field. The lamp build never
loads the depth model at all.
7.3 What the pipeline predicted about the two shells
§6.3 says the two shells transmit the same picture, and says it in the strongest possible form: not approximately, identically. That claim is correct, and it is worth putting a number on how correct. The two transmission maps this build exported differ on 2.59 % of their pixels, by a mean of 0.14 % of the range — and the whole of that difference is the overhang limiter, which the build now runs at a different angle for each shell, 60° outward against 70° inward. Nothing else in the two fields differs at all.
Beer–Lambert is a function of path length and nothing else. There is no term in it for where in the wall the thickness variation sits, and no term for light moving sideways. Both shells have the same t(θ, z) by construction, so the model is obliged to predict the same image — and it did.
Having established the tie, §6.3 ranked the shells on what was left over: unlit appearance, a second-order refraction argument, and overhang. A seam analysis done after this page was written then decided it, and decided it on printability. Redo §2.5's corner measurement on a luma field and the numbers invert: hard tonal edges become near-full-relief jumps across one or two samples, so the field's own sharpest concave corner is 27.0°, not 88.7°, and on the outward shell the channel loses to the picture on 99.8 % of layers. Beating 27° would need a groove about 6.7 mm deep — deeper than the wall. On the inward shell there are no rivals at all, because the outer contour is a circle with two notches in it: margin +127°, every layer, unprompted.
So the inward shell was picked, and L2 was printed inward first — on a printability argument, taken while the optical question was believed to be a tie.
7.4 The hand disagrees, and by a lot
They are not the same picture. On the outward shell the birch trunks have edges, the hunters have spears, the dogs are countable and the village has roofs. On the inward shell the same content is a grey weather system: the trees survive as soft columns, the figures as smudges, and everything below about two millimetres is gone.
“Looks blurrier” is not a measurement, so here is one. Each photograph is converted to log luminance and band-passed at a series of spatial scales; the scale axis is put in millimetres on the object using the drum's known 129.55 mm diameter as the ruler. Working in the log domain is what makes the photographs comparable: a JPEG value is roughly radiance to the power 1/2.2, so its logarithm is (1/2.2) × log radiance plus a constant, and a band-pass throws the constant away. Exposure and white balance drop out; the same constant of proportionality is left in every frame.
| feature size | 0.5 mm | 1 mm | 2 mm | 4 mm | 8 mm |
|---|---|---|---|---|---|
| outward, lamp on | 0.0201 | 0.0299 | 0.0407 | 0.0454 | 0.0385 |
| outward, lamp off | 0.0193 | 0.0271 | 0.0364 | 0.0423 | 0.0531 |
| inward, lamp on | 0.0028 | 0.0055 | 0.0127 | 0.0240 | 0.0302 |
| inward, lamp off | 0.0025 | 0.0049 | 0.0090 | 0.0111 | 0.0110 |
| inward ÷ outward, lamp on, each curve referred to its own 8 mm band | 0.18 | 0.23 | 0.40 | 0.68 | 1.00 — the anchor, not a result |
At eight-millimetre features the two objects are comparable. At two millimetres the inward shell holds 40 % of the outward shell's relative contrast, at one millimetre 23 %, at half a millimetre 18 %. The deficit is not a level shift; it is a slope, and it grows exactly as the features get smaller. Something is low-passing the inward shell.
7.5 Which channel is doing the work
The obvious explanation is that the wall diffuses, and that a thickness pattern written on the far side of a scattering wall has to diffuse across the whole wall before it can leave, while a pattern written on the exit face does not. That story is plausible, it is what lithophane practice has always assumed, and it is not what these photographs show. The control that settles it is free: turn the lamp off.
Read the top row. Turning the lamp on changes the outward shell's half-millimetre band by four percent — 0.0193 to 0.0201. At one millimetre the lamp buys ten percent, at two millimetres twelve, at four seven. Whatever is drawing the trunks and the dogs, it is not the light coming through the wall; it is room light landing on a relief. Above four millimetres the lamp does not merely fail to help, it actively costs: 0.0531 → 0.0385 at eight millimetres, because flooding the shell from inside fills in the relief's own shadows and substitutes its own broad tonality.
Now read the bottom row. With the lamp off the inward shell measures 0.0025 at half a millimetre — on a plain cylinder with no relief at all, which is the noise floor of this measurement. With the lamp on it measures 0.0028. The transmitted image contributes essentially nothing below a millimetre. What the lamp does supply is the coarse end, where it lifts the same object from 0.0110 to 0.0302 — nearly threefold.
§6.3 asked which shell transmits the better picture, and Beer–Lambert answered, correctly, neither, they are identical. The object answers a different question. An outward shell in a lit room is running two optical channels at once, perfectly registered because they are the same geometry: a transmissive one that supplies the tonality and a reflective one that supplies the drawing. An inward shell, by hiding the relief in the bore, keeps the channel that carries the tone and throws away the channel that carries the detail.
Put the modulation on the face you look at. Not because transmission through it is sharper — that is still unproven — but because a surface you can see is a second, free, exactly-registered picture, and a surface you have buried is not.
7.6 What these photographs do not establish
The house rule is that a headline claim carries its own controls, so here are the ones that were run and the one thing that is still open.
Whether the transmitted image alone is sharper on the outward shell. Both objects were photographed in a daylit room, so the outward shell always had its reflective channel running. The diffusion argument at the top of §7.5 may well be true; nothing here tests it. The experiment is one photograph of each shell in a dark room with the lamp as the only source, which is already on the list as the L2 verdict, and it now has a sharp prediction attached: in the dark the outward shell's advantage should largely collapse. If it does not, diffusion is real and measurable. Either way the design conclusion does not move, because both mechanisms point the same way and none points the other.
The three rival explanations, and how each was killed — focus, chance, and print droop
- rival 1: the inward photograph is simply softer — camera focus or shake
- Measure a feature that belongs to the camera and not to the object: the 10–90 % rise across the drum's own silhouette against the table. Outward, 1.21 and 2.11 mm on the two edges; inward, 1.41 and 1.41 mm. The inward frame is if anything the sharper photograph, and no plausible focus error removes 80 % of one-millimetre detail while leaving a silhouette that crisp.
- rival 2: it is one unlucky frame
- A second, independently framed photograph of the lit inward shell, at a different distance, reproduces the whole curve to within 0.014 at every one of the nine scales, and to within 0.005 across the six that carry the finding — 0.092 against 0.091 at half a millimetre, 0.421 against 0.417 at two.
- rival 3: print droop on the bore, which we made worse on purpose
CYL_LAMP_MAX_OVERHANG_DEGrelaxes the inward shell to 70° against the outward shell's 60°, on the stated argument that inward droop “lands on the bore, where it only blurs vertically and never touches that surface”. On the inward shell the bore is that surface, so the premise is plainly wrong and the number should not be inherited. But it is not the cause of this: droop acts along z, so it would blur horizontal edges far more than vertical ones. Band-passing along each axis separately gives an inward/outward ratio of 0.21 / 0.28 / 0.47 / 0.75 for horizontal edges and 0.24 / 0.24 / 0.37 / 0.66 for vertical ones at 0.5 / 1 / 2 / 4 mm. The loss is isotropic. Droop is a real defect on the wrong argument, not the mechanism here.- and one tidy answer that did not survive its own fit
- If the inward shell were the outward shell seen through a Gaussian blur, the contrast ratio would fall as exp(−2π2σ2/s2) and a single σ would fit all nine scales. It does not: the best fit leaves an rms residual of 0.21 on a quantity that only ranges 0–1, with the residual systematically S-shaped. Which is the right answer, because the two images do not differ by a blur — they differ by an added channel. The σ is not reported here because it does not exist.
Two smaller caveats belong on the record. The room light was daylight through large windows and was neither controlled nor measured, so the lamp-off and lamp-on frames of a pair are minutes apart rather than simultaneous. And every number above is a ratio taken within one photograph or between band-passes of the same log image, which is why none of them depends on μ — still a placeholder, still unmeasured, and still waiting on the wedge of §6.5.
7.7 The sky, the birds, and the two joins
This is also the register where the inward shell is at its best. Where the painting is broad tone — the sky, the frozen ponds, the far snowfield — the transmitted image is doing all the work on both objects and they converge. It is the drawing that is missing, not the picture.
Which is what mirror_wrap promised and, unusually for this page, delivered without a
correction: the joins are continuous to 0.0 because a reflection cannot have a step. The seam channel
itself stays invisible when the lamp is on, exactly as §2.5 intended — it is subtracted
from both surfaces, so the wall thickness never changes and the transmission genuinely cannot see it.
With the lamp off it shows as a faint vertical crease catching the room, which is the only place it
has ever been visible.
7.8 What changes in the recipe
CYL_LAMP_DRIVER = "luma", permanently. The depth lineage has no business on a wall read by transmission. This is settled by L1 and not open.- The outward shell is the build.
CYL_LAMP_MODESshould still export both — the run is one pass and the inward mesh costs nothing to keep — but outward is what goes on the plate. - Pay the seam by hand, and read §2.5 as history. Its 1.2 × 1.2 groove is what severed the wall; the shipped profile is 0.8 deep by 3.2 wide, whose flanks tilt 26.6° instead of 63° and leave a narrowest band of 0.744 mm, 1.77 beads. Even so the outward shell's channel loses on 99.8 % of layers against a luma field's 27° corners, and beating that would need a 6.7 mm groove, which is deeper than the wall. So paint the seam in the slicer: two minutes, free, robust, and the correct price for the better object. The measured alternatives were both worse — de-notching buys 110.5° for −7 % contrast and still loses on 28.6 % of layers, and a 0.5 mm circumferential blur fixes it completely at a cost of −24 % of the hunters' local contrast, which is the entire point of the object.
- Do not inherit the inward shell's 70°. Its justification does not survive §7.6. If an inward shell is ever built again it wants 60° like the other one, or a fresh argument.
- μ is still a placeholder and the wedge is still unprinted. Nothing in this section waits on it; the tone remap does.
A heightfield on a base surface has two faces, and the choice of which one carries the relief looked like a free one because the quantity in the transmission model — thickness — is symmetric between them. It is not free. The face that carries the modulation is the face that can also be seen, and a visible modulated surface is a second picture, in perfect register, at no cost in geometry, plastic or print time.
8. The code
Two new files, one config block. Nothing in heightfield.py, depth.py or
mesh.py changes.
| file | what is in it |
|---|---|
cylinder.py | circular_pad / circular_unpad,
apply_band_rim, limit_overhang, seam_step_mm,
heightfield_to_shell (both shells) and heightfield_to_cylinder,
validate, overhang_census, nozzle_preview_cyl; and for the
lamp, add_foot, transmission, tone_for_transmission,
calibration_wedge |
main_cyl.py | both builds, switched by CYL_BUILD.
condition_v6 is main.py's chain; condition_v1 is the
canonical four stages of §§1–11. Either runs on a circularly padded field, then
the ends, then the overhang limiter |
render_cyl.py | orthographic z-buffered render from analytic surface normals. A hillshade of a wrapped field shades a plane that no longer exists; the analytic normals here agree with the built mesh to within 2 parts in 10⁶ |
config.py | a CYL_* block: which build, geometry, overhang
limit, pad, layer height, the v1 constants v2+ retuned, and the CYL_LAMP_* group |
CYL_HEIGHT_MM = 170.0 # print height; A1 mini Z is 180 mm
CYL_RELIEF_MM = 16.0 # peak-to-valley, radial. Compare against R0, not width
CYL_WALL_MM = 4.0 # shell thickness at zero relief (was BASE_MM)
CYL_MAX_OVERHANG_DEG = 45.0 # radius growth per unit rise. None = off, supports on
CYL_PAD_MM = 30.0 # circular pad so filters see across the seam
CYL_REUSE_FLAT_DEPTH = False # True = reuse a depth cache inferred at another size
CYL_LAYER_MM = 0.20 # the layer height you will actually slice at
CYL_BUILD = "lamp" # or "column"
CYL_LAMP_HEIGHT_MM = 145.0 # circumference follows from the aspect ratio
CYL_LAMP_FLOOR_MM = 0.8 # thinnest wall = 2 perimeters at 0.4 mm
CYL_LAMP_RELIEF_MM = 2.4 # so the wall runs 0.8 -> 3.2 mm
CYL_LAMP_MU_PER_MM = 1.25 # PLACEHOLDER. Print the wedge and measure it.
CYL_LAMP_TONE = "linear-light" # solve thickness for the brightness you want
depth_signature includes the working resolution, and the working resolution is
derived from the print grid. The three geometries therefore want three different depth maps: the
flat panel infers at 1360 × 968, the column at 1910 × 1360, the lamp
shell at 1630 × 1160. So the first run of each build re-infers, which is correct and
desirable — a larger working image means more tiles in tiled_refine and genuinely
more detail — but it is a model run, not seconds.
Each geometry gets its own cache file, depth_cache_WxH.npz, and
main.py's depth_cache.npz is read but never written by
main_cyl.py. One shared file would mean every switch between the panel, the column and
the shell silently threw away the previous build's model run — and you would not find out
until the next run took twenty minutes instead of twenty seconds.
CYL_REUSE_FLAT_DEPTH = True borrows the flat cache for geometry work and prints a
warning saying so; every figure on this page was made that way, so the fine detail in them is the
flat build’s, not what a real run will produce.
9. What it opens
The reason this branch is worth its evening is not either object. It is that
heightfield_to_shell is an instance of a general move, and several other ideas on the
list are the same move with a different base surface — or, in the lamp's case, the same
surface read by a different kind of light.
| idea | what it needs beyond this page |
|---|---|
| the ambient lamp — light inside, rotating, shadows on the wall | The shell is built (§6) and the inverse problem is solved for the wall itself: thickness is chosen from the brightness it should transmit, not from the depth map. What is left is a source, a diffuser, a mechanism and the projection question — §6.8. |
| the sphere | Same generalisation, harder mapping: no distortion-free map from a rectangle to a sphere exists, so a projection has to be chosen and its distortion budgeted. The overhang argument of §3 gets worse — near the equator the surface already leans. |
| the cuboid, features distributed across five faces | Blocked on feature extraction, not on geometry. Five flat panels is branch A run five times. |
| the tiered constructor relief | Unrelated to the base surface — it quantises the depth axis instead of the spatial ones. It composes with this page: a tiered column is legal. |
References
- The build guide — §7.2 for the heightfield assumption this page generalises, §12–13 for the carving stages that run unchanged, §10 for the slicer settings that matter more here.
- painting2lamp — the lamp path of §6 as a single
browser page: image in, STL out, offline, verified against
cylinder.pyto 1.1e−5 mm. - From Relief to Sculpture — the other branch out of the same heightfield, which spends the assumption rather than moving it.
- The Poisson solve — still the engine under
compress_rangeandlimit_slope, both of which run here unmodified. - Weyrich et al. (2007), Digital Bas-Relief from 3D Scenes — the compression this whole family rests on says nothing about the base surface, which is why the wrap is cheap.
- Yuan, Bourell et al., Additive Manufacturing of Optically Translucent Parts (SFF Symposium 2014) — applies Beer–Lambert to printed polymer plates from 0.38 to 4.74 mm and finds transmittance falling monotonically with thickness. It reports no coefficient you can borrow, which is §6.5's whole point.
- Lithophane 3D printing guide — the 0.6–0.8 mm / 2.8–3.2 mm working window, white PLA, 0.12 mm layers, 100 % infill and maximum wall loops. Conventions, not measurements; checked 2026-08-05.