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.

four views of the wrapped relief
The wrapped field, rendered from its own analytic surface normals with the bead and layer simulation of §4 applied. 170 mm tall, 238.9 mm around, relief 16 mm radial. Only the near half is drawn; the picture continues around the back.

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.

circumference = height × aspect = 170 mm × 1.4051 = 238.87 mm
R0 = circumference / 2π = 38.02 mm
quantityvaluewhy that value
print height170 mmthe A1 mini’s 180 mm Z, with margin
circumference238.87 mmforced by the aspect ratio 6819 × 4853
base diameter76.03 mmwhere the relief is zero
diameter at the peaks108.03 mmbase + 2 × CYL_RELIEF_MM
bore68.03 mmbase − 2 × CYL_WALL_MM; a plain cylinder, left clear
field grid955 × 680PITCH_MM 0.25 on both axes
arc pitch, base → peak0.250 → 0.355 mmsamples 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.

relief 10 mm versus 16 mm
10 mm vs 16 mm. Both at 45° overhang limit, both with the wrapped nozzle simulation. 10 mm: 96.0 mm across, 313.8 cm³ enclosed. 16 mm: 108.0 mm across, 420.0 cm³. The v6 argument for deep relief was that at grazing angles a thin bas-relief reads as terrain; on a column there is always a near-frontal region, so the argument is weaker here than it was on the panel.
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_MM to 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.

cylinder.py
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.

Why it costs nothing — the groove is cut from both surfaces

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.

And it is free everywhere else. The channel is constant in z, so it contributes nothing to the overhang census (verified: zero airborne faces added). Enclosed volume moves by 0.014 %, which is the groove’s own small displacement. The one trap is in the mesher: a bore with a channel in it still has the same profile at every height, so _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.

Around the column — no constraint at all

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.

Up the column — the 45° rule, in full force

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.

The old slope limit does not protect you. 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:

cylinder.py — limit_overhang
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.

the field with and without the overhang limit
the 45° limiter costs almost nothing. Area-weighted overhang census on the built mesh. Unlimited: 9182 mm² of surface within 45° of straight down, in air. Limited: zero — the only downward-facing surface left is the 905 mm² bottom annulus, which sits on the build plate. Cost: 3.7 % of the summed height budget, touching 14 % of the field. The two renders are nearly indistinguishable, which is the point.

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 resolvedflat panelstanding cylinder
the relief itself (depth)layer height, 0.08 mmtoolpath radius — not quantised at all
picture’s horizontal axisbead width, 0.42 mmbead width, 0.42 mm
picture’s vertical axisbead width, 0.42 mmlayer 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.

flat nozzle simulation versus wrapped
judge the right simulation. Same field, same viewpoint. Left: the flat panel’s simulation, which low-passes the vertical axis at the bead width and quantises the relief. Right: the wrapped simulation at a 0.2 mm layer. The branches and the engraved drawing survive on the right because on a column they are resolved by the layer stack, not by the bead.

5. Printing the column

flat v6 panelthis column
bounding box170 × 121 × 20 mm103.1 × 100.7 × 170 mm
enclosed volume216.7 cm³420.0 cm³ — 1.94×
footprint on the plate170 × 121 mma 4 mm ring, 905 mm²
layers250 at 0.08 mm850 at 0.2 mm
supportsnonenone, given §3
mesh331,445 verts, watertight730,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.py at v6 settings re-run alongside main_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.

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.

the lamp shell, off and lit, at three turns
The outward shell: 145 mm tall, 203.7 mm around, wall 0.8–3.2 mm. Unlit it is a shallow relief; lit from inside it is the painting. Both renders are the same mesh with the same wrapped nozzle simulation — only the shading model differs, and that difference is the entire design problem of this section.

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:

engrave_lines would print the drawing as a negative

§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.

The general form: on a relief, low means shadow. On a lithophane, low means light. Any stage tuned on the first meaning has to be re-derived, not re-used.

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

wall thickness  t(θ, z) = FLOOR + RELIEF × h(θ, z)  =  0.8 + 2.4 h  mm
quantitythe columnthe lamp shell
recipev6 carvedv1 canonical
height170 mm145 mm
circumference238.87 mm203.74 mm
base diameter76.03 mm64.85 mm
wall4 mm, plus up to 16 mm of relief0.8 → 3.2 mm, and that is all of it
bore68.03 mm63.25 mm (outward) / 58.45 mm min (inward)
relief as a fraction of R042 %7.4 %
arc pitch, base → peak0.250 → 0.355 mm0.250 → 0.269 mm
field grid955 × 680815 × 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.

outward and inward shells, off and lit
Off, they could hardly differ more: the outward shell is a shallow relief, the inward one is a blank tube. Lit, the difference collapses to a faint extra shading on the outward shell's own slopes, because the attenuating quantity — thickness — is the same field in both.

So the choice is not about the image. It is about three other things.

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 at R0 - FLOOR - RELIEF*h, so leaning in over the bore also means h growing. Both reduce to dh/dz ≤ tan(45°)/RELIEF, which is what limit_overhang enforces — 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:

I / I0 = exp(−μt)

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:

cylinder.py — tone_for_transmission
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.

linear thickness versus linear light
The same field, twice. Left, 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.
A polarity that happens to work, and will not always

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:

the calibration wedge
calibration_wedge.stl — 13 steps from 0.4 to 4.0 mm, 104 × 24 × 4 mm, 5.5 cm³, watertight. Brightness shown at the placeholder μ with a display gamma, so all thirteen steps stay separable on a page — the eye does not get that gamma from a real lamp, which is §6.4's point. Print it in the filament and at the exact layer height and wall settings you mean to use, lay it over the lamp, photograph it once, and plot ln(I0/I) against t. The slope is μ. How far the plot departs from a straight line is how much of the loss is scattering rather than absorption — and scattering is the part that print settings move.

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_mesh builds 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 columnthe lamp shell (outward / inward)
bounding box103.1 × 100.7 × 170 mm68.3 × 68.1 × 145 / 64.85 × 64.85 × 145 mm
enclosed volume420.0 cm³35.6 / 35.1 cm³ — about a twelfth
mesh730,575 verts531,380 / 945,400 verts, both watertight, Euler 0
footprinta 4 mm ring, 905 mm²a 2.4 mm ring, 471 mm²
airborne overhang8.4 % before the limiter0.017 % before the limiter
layer height0.2 mm (§4)0.12 mm — see below
layers8501,208

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:


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.

Read §§1–6 as the state of the design before the prints

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.

L1 printed: unlit, the join, and with a torch inside
L1, printed. Left, in daylight with nothing inside: a blank white tube. Centre, the single-wrap join, which is the most legible feature on the object. Right, a torch pressed against the bore — the wall glows and the painting does not arrive.

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.

§6.4 wrote the diagnosis before the print existed

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 general form: when a stage is correct and the object built on it is blank, suspect the quantity the stage was fed, not the stage.

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.

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.

quantityL1L2
driverdepth — v1 recipe on the depth mapluma — the painting's own tonality
wraponce aroundmirrored — picture + reflection
height145 mm145 mm
field grid at 0.25 mm815 × 5801628 × 580 — 2 × 815 − 2, the shared end columns counted once
circumference203.75 mm407.00 mm
base diameter64.86 mm129.55 mm
bore63.26 mm outward127.95 mm outward / 123.15 mm min inward
wall0.8 → 3.2 mm, unchanged — the optical window of §6.2 does not care about any of this
joinsone seamtwo mirror lines
local contrast, hunters0.01460.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.

I / I0 = exp(−μt)

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

the outward and inward shells, both lit, side by side
Both shells, both lit, matched to the same apparent diameter. Left, the relief facing out. Right, the relief facing in. The transmission model says these are the same picture.

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.

0.00 0.01 0.02 0.03 0.04 0.05 0.5 1 2 4 8 feature size on the object (mm) local contrast — rms band-passed log luminance outward relief, lamp OFF · 0.5 mm · 0.0193 outward relief, lamp OFF · 0.75 mm · 0.0230 outward relief, lamp OFF · 1 mm · 0.0271 outward relief, lamp OFF · 1.5 mm · 0.0328 outward relief, lamp OFF · 2 mm · 0.0364 outward relief, lamp OFF · 3 mm · 0.0399 outward relief, lamp OFF · 4 mm · 0.0423 outward relief, lamp OFF · 6 mm · 0.0476 outward relief, lamp OFF · 8 mm · 0.0531 outward relief, lamp OFF outward relief, lamp ON · 0.5 mm · 0.0201 outward relief, lamp ON · 0.75 mm · 0.0250 outward relief, lamp ON · 1 mm · 0.0299 outward relief, lamp ON · 1.5 mm · 0.0366 outward relief, lamp ON · 2 mm · 0.0407 outward relief, lamp ON · 3 mm · 0.0446 outward relief, lamp ON · 4 mm · 0.0454 outward relief, lamp ON · 6 mm · 0.0429 outward relief, lamp ON · 8 mm · 0.0385 outward relief, lamp ON inward relief, lamp OFF · 0.5 mm · 0.0025 inward relief, lamp OFF · 0.75 mm · 0.0036 inward relief, lamp OFF · 1 mm · 0.0049 inward relief, lamp OFF · 1.5 mm · 0.0074 inward relief, lamp OFF · 2 mm · 0.0090 inward relief, lamp OFF · 3 mm · 0.0106 inward relief, lamp OFF · 4 mm · 0.0111 inward relief, lamp OFF · 6 mm · 0.0108 inward relief, lamp OFF · 8 mm · 0.0110 inward relief, lamp OFF inward relief, lamp ON · 0.5 mm · 0.0028 inward relief, lamp ON · 0.75 mm · 0.0039 inward relief, lamp ON · 1 mm · 0.0055 inward relief, lamp ON · 1.5 mm · 0.0091 inward relief, lamp ON · 2 mm · 0.0127 inward relief, lamp ON · 3 mm · 0.0191 inward relief, lamp ON · 4 mm · 0.0240 inward relief, lamp ON · 6 mm · 0.0295 inward relief, lamp ON · 8 mm · 0.0302 inward relief, lamp ON
Local contrast against feature size, measured off the four photographs. Blue is the outward shell, orange the inward one; solid is the lamp on, dashed the lamp off. The two blue curves lie almost on top of each other below 4 mm. The orange pair does not separate until about 2 mm, and both orange curves sit far below both blue ones everywhere the picture's drawing lives. Hover any point for its value.
feature size0.5 mm1 mm2 mm4 mm8 mm
outward, lamp on0.02010.02990.04070.04540.0385
outward, lamp off0.01930.02710.03640.04230.0531
inward, lamp on0.00280.00550.01270.02400.0302
inward, lamp off0.00250.00490.00900.01110.0110
inward ÷ outward, lamp on, each curve referred to its own 8 mm band 0.180.230.400.681.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.

both shells photographed with the lamp off and the lamp on
The control. Same two objects, same table, same daylight, lamp off and lamp on. Top left is the whole finding: with no lamp at all, the outward shell is already the picture.

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.

The model was right; the question it answered was not the one that decides

§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.

The general form: a model can be exactly right and still rank two designs wrongly, if the quantity it models is not the quantity that decides. §6.3 even had the fact — its first bullet says the outward shell is an object when the lamp is off — and scored it as an aesthetic footnote for the hours the lamp is off, rather than as an optical channel that runs whenever the room has light in it.

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.

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_DEG relaxes 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

the sky and the birds on both shells
The upper register, where the picture is nearly all tone and hardly any drawing. The birds over the ridge are a two-millimetre mark on a smooth field. On the outward shell they are there. On the inward shell the ridge survives and the birds do not, which is the chart's one-to-two-millimetre column stated in feathers.

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.

the mirror axes of the kaleidoscope wrap
The kaleidoscope join, lit. Each axis is a line of symmetry with the picture running into it and back out again. Measured across it there is a broad brightness band, about 10 mm wide and 18 % above its surroundings — the width and the softness both say this is the painting's own bright edge doubled by the reflection, not a step. No narrow discontinuity is measurable at either axis.

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

The rung, for the next base surface

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.

Carried forward to the sphere and the cuboid: ask which faces of the new base surface a viewer can actually reach, and put the field on those.

8. The code

Two new files, one config block. Nothing in heightfield.py, depth.py or mesh.py changes.

filewhat is in it
cylinder.pycircular_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.pyboth 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.pyorthographic 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.pya CYL_* block: which build, geometry, overhang limit, pad, layer height, the v1 constants v2+ retuned, and the CYL_LAMP_* group
config.py
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
The one gotcha — the depth cache goes stale

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.

ideawhat 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 sphereSame 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 facesBlocked on feature extraction, not on geometry. Five flat panels is branch A run five times.
the tiered constructor reliefUnrelated 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