diff --git a/docs/gallery/assets/png-exr-alpha-hero.webp b/docs/gallery/assets/png-exr-alpha-hero.webp
index dc32d3c..3dc1390 100644
Binary files a/docs/gallery/assets/png-exr-alpha-hero.webp and b/docs/gallery/assets/png-exr-alpha-hero.webp differ
diff --git a/docs/gallery/contact-sheets/png-exr-alpha-restage.png b/docs/gallery/contact-sheets/png-exr-alpha-restage.png
new file mode 100644
index 0000000..0c24189
Binary files /dev/null and b/docs/gallery/contact-sheets/png-exr-alpha-restage.png differ
diff --git a/docs/gallery/contact-sheets/png-exr-alpha-restage.webp b/docs/gallery/contact-sheets/png-exr-alpha-restage.webp
new file mode 100644
index 0000000..15fa3ed
Binary files /dev/null and b/docs/gallery/contact-sheets/png-exr-alpha-restage.webp differ
diff --git a/docs/gallery/png-exr-alpha/index.html b/docs/gallery/png-exr-alpha/index.html
index f60622e..ccb3f46 100644
--- a/docs/gallery/png-exr-alpha/index.html
+++ b/docs/gallery/png-exr-alpha/index.html
@@ -552,8 +552,12 @@
Source
Columns = rising alpha; top rows = dark mid-tones that PNG clamps to
white; lower rows = primaries that survive. Left panel bakes the
closed-form mangling; right panel shows authored straight RGBA.
+
+ A dark bezel is painted into the texture so panel edges read as framed
+ objects in the studio still (render-only — checks use fill_pattern).
"""
gw, gh = 256, 192
+ border = 10
img = bpy.data.images.new(name, gw, gh, alpha=True, float_buffer=True)
img.colorspace_settings.name = "Non-Color"
px = [0.0] * (gw * gh * 4)
@@ -567,25 +571,32 @@ Source
(0.2, 0.35, 1.0), # blue-leaning; G/B still clamp at low a → wash
]
alphas = [1 / 255, 8 / 255, 32 / 255, 64 / 255, 128 / 255, 1.0]
- col_w = gw // n_cols
- row_h = gh // len(row_colors)
+ iw, ih = gw - 2 * border, gh - 2 * border
+ col_w = max(1, iw // n_cols)
+ row_h = max(1, ih // len(row_colors))
+ bezel = (0.04, 0.042, 0.048)
for y in range(gh):
- ri = min(y // row_h, len(row_colors) - 1)
- rgb = row_colors[ri]
for x in range(gw):
- ci = min(x // col_w, n_cols - 1)
+ i = (y * gw + x) * 4
+ if x < border or y < border or x >= gw - border or y >= gh - border:
+ px[i : i + 4] = [bezel[0], bezel[1], bezel[2], 1.0]
+ continue
+ xi, yi = x - border, y - border
+ ri = min(yi // row_h, len(row_colors) - 1)
+ ci = min(xi // col_w, n_cols - 1)
+ rgb = row_colors[ri]
a = alphas[ci]
if mangled:
r, g, b, _a = expected_float_png_rgba(*rgb, a)
else:
r, g, b = rgb
- i = (y * gw + x) * 4
px[i : i + 4] = [r, g, b, 1.0]
img.pixels.foreach_set(px)
return img
def easel_plane(name, image, loc, rot_z_deg):
+ """Single-plane easel + short stand — no dual-surface z-fight."""
me = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
@@ -610,19 +621,46 @@ Source
tex = nodes.new("ShaderNodeTexImage")
tex.image = image
tex.interpolation = "Closest"
+ links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
- bsdf.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0)
bsdf.inputs["Roughness"].default_value = 1.0
specular_off(bsdf)
- bsdf.inputs["Emission Strength"].default_value = 0.95
+ # Faint emission so clamps read under a dim stage without washing the wall.
+ bsdf.inputs["Emission Strength"].default_value = 0.22
me.materials.append(mat)
- obj = bpy.data.objects.new(name, me)
- obj.location = loc
- obj.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg))
- obj.scale = (1.15, 1.15, 1.15)
- bpy.context.collection.objects.link(obj)
- return obj
+ root = bpy.data.objects.new(name, None)
+ root.location = loc
+ root.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg))
+ root.scale = (0.88, 0.88, 0.88)
+ bpy.context.collection.objects.link(root)
+
+ face = bpy.data.objects.new(name + "Face", me)
+ bpy.context.collection.objects.link(face)
+ face.parent = root
+
+ stand_me = bpy.data.meshes.new(name + "Stand")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bm.to_mesh(stand_me)
+ finally:
+ bm.free()
+ smat = bpy.data.materials.new(name + "StandMat")
+ smat.use_nodes = True
+ sb = smat.node_tree.nodes["Principled BSDF"]
+ sb.inputs["Base Color"].default_value = (0.045, 0.048, 0.055, 1.0)
+ sb.inputs["Roughness"].default_value = 0.45
+ if "Metallic" in sb.inputs:
+ sb.inputs["Metallic"].default_value = 0.3
+ stand_me.materials.append(smat)
+ stand = bpy.data.objects.new(name + "Stand", stand_me)
+ # Fully below the card face — never intersects the evidence plane.
+ stand.scale = (0.06, 0.06, 0.28)
+ stand.location = (0.0, 0.04, -0.88)
+ bpy.context.collection.objects.link(stand)
+ stand.parent = root
+ return root
def render_still(path, engine):
@@ -631,8 +669,27 @@ Source
png_card = make_gallery_card("PngMangled", mangled=True)
exr_card = make_gallery_card("ExrClean", mangled=False)
- easel_plane("PngPanel", png_card, (-1.22, 0.0, 1.18), 10)
- easel_plane("ExrPanel", exr_card, (1.22, 0.0, 1.18), -10)
+ # Mounted easels on a shared dark table — continuous base, no V-tear shadow.
+ easel_plane("PngPanel", png_card, (-1.15, 0.1, 1.28), 5)
+ easel_plane("ExrPanel", exr_card, (1.15, 0.1, 1.28), -5)
+
+ table_me = bpy.data.meshes.new("Table")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bm.to_mesh(table_me)
+ finally:
+ bm.free()
+ tmat = bpy.data.materials.new("TableMat")
+ tmat.use_nodes = True
+ tb = tmat.node_tree.nodes["Principled BSDF"]
+ tb.inputs["Base Color"].default_value = (0.022, 0.024, 0.028, 1.0)
+ tb.inputs["Roughness"].default_value = 0.6
+ table_me.materials.append(tmat)
+ table = bpy.data.objects.new("Table", table_me)
+ table.scale = (1.5, 0.55, 0.08)
+ table.location = (0.0, 0.25, 0.28)
+ scene.collection.objects.link(table)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -644,20 +701,20 @@ Source
fmat = bpy.data.materials.new("Studio")
fmat.use_nodes = True
fb = fmat.node_tree.nodes["Principled BSDF"]
- fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
- fb.inputs["Roughness"].default_value = 0.7
+ fb.inputs["Base Color"].default_value = (0.02, 0.021, 0.025, 1.0)
+ fb.inputs["Roughness"].default_value = 0.8
floor_me.materials.append(fmat)
floor = bpy.data.objects.new("Floor", floor_me)
scene.collection.objects.link(floor)
wall = bpy.data.objects.new("Wall", floor_me.copy())
- wall.location = (0.0, 6.8, 0.0)
+ wall.location = (0.0, 8.8, 0.0)
wall.rotation_euler = (math.radians(90), 0.0, 0.0)
scene.collection.objects.link(wall)
world = bpy.data.worlds.new("World")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
- 0.02, 0.021, 0.025, 1.0,
+ 0.012, 0.013, 0.015, 1.0,
)
scene.world = world
@@ -671,19 +728,19 @@ Source
ob.rotation_euler = tuple(math.radians(a) for a in rot)
scene.collection.objects.link(ob)
- light("Key", (-3.8, -4.8, 5.8), 360.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
- light("Fill", (4.8, -2.8, 1.6), 70.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
- light("Rim", (0.2, 4.0, 2.8), 220.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
- light("Wedge", (0.5, 5.2, 2.8), 520.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180))
+ light("Key", (-3.8, -4.8, 5.8), 220.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
+ light("Fill", (4.8, -2.8, 1.6), 40.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
+ light("Rim", (0.2, 4.8, 2.8), 120.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
+ light("Wedge", (0.5, 6.5, 2.5), 450.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180))
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.0, 1.1)
+ aim.location = (0.0, 0.1, 1.15)
scene.collection.objects.link(aim)
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 45.0
+ cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -5.4, 2.2)
+ cam.location = (0.0, -6.0, 2.15)
con = cam.constraints.new("TRACK_TO")
con.target = aim
con.track_axis = "TRACK_NEGATIVE_Z"
diff --git a/examples/png-exr-alpha/png_exr_alpha.py b/examples/png-exr-alpha/png_exr_alpha.py
index 6aec540..7a6df8c 100644
--- a/examples/png-exr-alpha/png_exr_alpha.py
+++ b/examples/png-exr-alpha/png_exr_alpha.py
@@ -344,8 +344,12 @@ def make_gallery_card(name, mangled):
Columns = rising alpha; top rows = dark mid-tones that PNG clamps to
white; lower rows = primaries that survive. Left panel bakes the
closed-form mangling; right panel shows authored straight RGBA.
+
+ A dark bezel is painted into the texture so panel edges read as framed
+ objects in the studio still (render-only — checks use fill_pattern).
"""
gw, gh = 256, 192
+ border = 10
img = bpy.data.images.new(name, gw, gh, alpha=True, float_buffer=True)
img.colorspace_settings.name = "Non-Color"
px = [0.0] * (gw * gh * 4)
@@ -359,25 +363,32 @@ def make_gallery_card(name, mangled):
(0.2, 0.35, 1.0), # blue-leaning; G/B still clamp at low a → wash
]
alphas = [1 / 255, 8 / 255, 32 / 255, 64 / 255, 128 / 255, 1.0]
- col_w = gw // n_cols
- row_h = gh // len(row_colors)
+ iw, ih = gw - 2 * border, gh - 2 * border
+ col_w = max(1, iw // n_cols)
+ row_h = max(1, ih // len(row_colors))
+ bezel = (0.04, 0.042, 0.048)
for y in range(gh):
- ri = min(y // row_h, len(row_colors) - 1)
- rgb = row_colors[ri]
for x in range(gw):
- ci = min(x // col_w, n_cols - 1)
+ i = (y * gw + x) * 4
+ if x < border or y < border or x >= gw - border or y >= gh - border:
+ px[i : i + 4] = [bezel[0], bezel[1], bezel[2], 1.0]
+ continue
+ xi, yi = x - border, y - border
+ ri = min(yi // row_h, len(row_colors) - 1)
+ ci = min(xi // col_w, n_cols - 1)
+ rgb = row_colors[ri]
a = alphas[ci]
if mangled:
r, g, b, _a = expected_float_png_rgba(*rgb, a)
else:
r, g, b = rgb
- i = (y * gw + x) * 4
px[i : i + 4] = [r, g, b, 1.0]
img.pixels.foreach_set(px)
return img
def easel_plane(name, image, loc, rot_z_deg):
+ """Single-plane easel + short stand — no dual-surface z-fight."""
me = bpy.data.meshes.new(name)
bm = bmesh.new()
try:
@@ -402,19 +413,46 @@ def easel_plane(name, image, loc, rot_z_deg):
tex = nodes.new("ShaderNodeTexImage")
tex.image = image
tex.interpolation = "Closest"
+ links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
links.new(tex.outputs["Color"], bsdf.inputs["Emission Color"])
- bsdf.inputs["Base Color"].default_value = (0.0, 0.0, 0.0, 1.0)
bsdf.inputs["Roughness"].default_value = 1.0
specular_off(bsdf)
- bsdf.inputs["Emission Strength"].default_value = 0.95
+ # Faint emission so clamps read under a dim stage without washing the wall.
+ bsdf.inputs["Emission Strength"].default_value = 0.22
me.materials.append(mat)
- obj = bpy.data.objects.new(name, me)
- obj.location = loc
- obj.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg))
- obj.scale = (1.15, 1.15, 1.15)
- bpy.context.collection.objects.link(obj)
- return obj
+ root = bpy.data.objects.new(name, None)
+ root.location = loc
+ root.rotation_euler = (math.radians(62), 0.0, math.radians(rot_z_deg))
+ root.scale = (0.88, 0.88, 0.88)
+ bpy.context.collection.objects.link(root)
+
+ face = bpy.data.objects.new(name + "Face", me)
+ bpy.context.collection.objects.link(face)
+ face.parent = root
+
+ stand_me = bpy.data.meshes.new(name + "Stand")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bm.to_mesh(stand_me)
+ finally:
+ bm.free()
+ smat = bpy.data.materials.new(name + "StandMat")
+ smat.use_nodes = True
+ sb = smat.node_tree.nodes["Principled BSDF"]
+ sb.inputs["Base Color"].default_value = (0.045, 0.048, 0.055, 1.0)
+ sb.inputs["Roughness"].default_value = 0.45
+ if "Metallic" in sb.inputs:
+ sb.inputs["Metallic"].default_value = 0.3
+ stand_me.materials.append(smat)
+ stand = bpy.data.objects.new(name + "Stand", stand_me)
+ # Fully below the card face — never intersects the evidence plane.
+ stand.scale = (0.06, 0.06, 0.28)
+ stand.location = (0.0, 0.04, -0.88)
+ bpy.context.collection.objects.link(stand)
+ stand.parent = root
+ return root
def render_still(path, engine):
@@ -423,8 +461,27 @@ def render_still(path, engine):
png_card = make_gallery_card("PngMangled", mangled=True)
exr_card = make_gallery_card("ExrClean", mangled=False)
- easel_plane("PngPanel", png_card, (-1.22, 0.0, 1.18), 10)
- easel_plane("ExrPanel", exr_card, (1.22, 0.0, 1.18), -10)
+ # Mounted easels on a shared dark table — continuous base, no V-tear shadow.
+ easel_plane("PngPanel", png_card, (-1.15, 0.1, 1.28), 5)
+ easel_plane("ExrPanel", exr_card, (1.15, 0.1, 1.28), -5)
+
+ table_me = bpy.data.meshes.new("Table")
+ bm = bmesh.new()
+ try:
+ bmesh.ops.create_cube(bm, size=1.0)
+ bm.to_mesh(table_me)
+ finally:
+ bm.free()
+ tmat = bpy.data.materials.new("TableMat")
+ tmat.use_nodes = True
+ tb = tmat.node_tree.nodes["Principled BSDF"]
+ tb.inputs["Base Color"].default_value = (0.022, 0.024, 0.028, 1.0)
+ tb.inputs["Roughness"].default_value = 0.6
+ table_me.materials.append(tmat)
+ table = bpy.data.objects.new("Table", table_me)
+ table.scale = (1.5, 0.55, 0.08)
+ table.location = (0.0, 0.25, 0.28)
+ scene.collection.objects.link(table)
floor_me = bpy.data.meshes.new("Floor")
bm = bmesh.new()
@@ -436,20 +493,20 @@ def render_still(path, engine):
fmat = bpy.data.materials.new("Studio")
fmat.use_nodes = True
fb = fmat.node_tree.nodes["Principled BSDF"]
- fb.inputs["Base Color"].default_value = (0.03, 0.032, 0.037, 1.0)
- fb.inputs["Roughness"].default_value = 0.7
+ fb.inputs["Base Color"].default_value = (0.02, 0.021, 0.025, 1.0)
+ fb.inputs["Roughness"].default_value = 0.8
floor_me.materials.append(fmat)
floor = bpy.data.objects.new("Floor", floor_me)
scene.collection.objects.link(floor)
wall = bpy.data.objects.new("Wall", floor_me.copy())
- wall.location = (0.0, 6.8, 0.0)
+ wall.location = (0.0, 8.8, 0.0)
wall.rotation_euler = (math.radians(90), 0.0, 0.0)
scene.collection.objects.link(wall)
world = bpy.data.worlds.new("World")
world.use_nodes = True
world.node_tree.nodes["Background"].inputs["Color"].default_value = (
- 0.02, 0.021, 0.025, 1.0,
+ 0.012, 0.013, 0.015, 1.0,
)
scene.world = world
@@ -463,19 +520,19 @@ def light(name, loc, energy, size, col, rot):
ob.rotation_euler = tuple(math.radians(a) for a in rot)
scene.collection.objects.link(ob)
- light("Key", (-3.8, -4.8, 5.8), 360.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
- light("Fill", (4.8, -2.8, 1.6), 70.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
- light("Rim", (0.2, 4.0, 2.8), 220.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
- light("Wedge", (0.5, 5.2, 2.8), 520.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180))
+ light("Key", (-3.8, -4.8, 5.8), 220.0, 4.5, (1.0, 0.96, 0.9), (52, 0, -32))
+ light("Fill", (4.8, -2.8, 1.6), 40.0, 9.0, (0.75, 0.85, 1.0), (72, 0, 52))
+ light("Rim", (0.2, 4.8, 2.8), 120.0, 3.2, (0.6, 0.78, 1.0), (-62, 0, 178))
+ light("Wedge", (0.5, 6.5, 2.5), 450.0, 7.0, (1.0, 0.72, 0.42), (-78, 0, 180))
aim = bpy.data.objects.new("Aim", None)
- aim.location = (0.0, 0.0, 1.1)
+ aim.location = (0.0, 0.1, 1.15)
scene.collection.objects.link(aim)
cam_data = bpy.data.cameras.new("Cam")
- cam_data.lens = 45.0
+ cam_data.lens = 50.0
cam = bpy.data.objects.new("Cam", cam_data)
- cam.location = (0.0, -5.4, 2.2)
+ cam.location = (0.0, -6.0, 2.15)
con = cam.constraints.new("TRACK_TO")
con.target = aim
con.track_axis = "TRACK_NEGATIVE_Z"
diff --git a/examples/png-exr-alpha/preview.webp b/examples/png-exr-alpha/preview.webp
index 7e448c9..b419ced 100644
Binary files a/examples/png-exr-alpha/preview.webp and b/examples/png-exr-alpha/preview.webp differ